diff --git a/.changeset/confirm-gate-english-clause.md b/.changeset/confirm-gate-english-clause.md new file mode 100644 index 0000000000..17802e0aac --- /dev/null +++ b/.changeset/confirm-gate-english-clause.md @@ -0,0 +1,41 @@ +--- +"@object-ui/i18n": patch +"@object-ui/plugin-chatbot": patch +"@object-ui/app-shell": patch +--- + +fix(i18n): the change card's Confirm button sent text the cloud gate does not accept + +The English `console.ai.changesConfirmMessage` was +`"Confirm the changes — apply what you just proposed."`. The cloud confirm gate +(`service-ai-studio` `confirm-gate.ts` `APPROVAL_RE`) recognises +`apply (this|the) change` — **not** "apply what". So the message failed the +gate, and failing the gate is silent: the agent re-proposes instead of applying, +and the Confirm button on the change card simply looks inert. + +This affected English conversations **and all eight locales that fall back to +English** for that key. It is now +`"Confirm — apply the change you just proposed."` — singular "the change", so it +still matches if the gate ever tightens to a word boundary. The Chinese string +was always fine (`确认修改` hits the 确认-anchored clause) and is unchanged. + +The same literal lives in four places — the locale pack, the +`ChatbotEnhanced` prop default, its doc comment, and the `AiChatPage` +`defaultValue` — and all four are updated together. + +**Why the existing guard missed it.** `i18n.test.ts` mirrored only the *Chinese* +clause of `APPROVAL_RE`; the English half was reduced to "starts with Confirm, +contains apply" because nothing in this repo could see the real pattern. That +weaker assertion passed against a string the gate rejected — the guard was +green and the feature was broken. + +The mirror is now **verbatim, both clauses**, and drives an `it.each` over every +outbound approval message in both `zh` and `en`. Two supporting tests keep it +honest: one asserting the gate stays narrow (a plain build request like +"帮我搭建一个 CRM" must NOT read as approval), and one asserting +`planAnswerMessage` does *not* match — it answers a structure question and must +never read as blanket approval. + +The mirror is duplicated across a repo boundary by necessity (objectui cannot +import from cloud); the comment says so, so the next person changing +`APPROVAL_RE` knows to update it here too. diff --git a/.changeset/high-frequency-parity-ratchet.md b/.changeset/high-frequency-parity-ratchet.md new file mode 100644 index 0000000000..df6e2176a6 --- /dev/null +++ b/.changeset/high-frequency-parity-ratchet.md @@ -0,0 +1,31 @@ +--- +"@object-ui/i18n": patch +--- + +test(i18n): ratchet the four backfilled namespaces so they cannot silently erode + +objectui#2903 translated `console`, `home`, `topbar` and `layout` into all ten +packs. Nothing stopped that from decaying: `fallbackLng: 'en'` means dropping a +key from `de` renders English, which reads as "not translated yet" rather than +"we lost this", and the missing-key handler is dev-only so CI never sees it. + +This is objectui#2872's P3 (full parity test) applied **only to the namespaces +that are actually complete**. Full parity would fail today by ~277 keys per +pack with no action attached to it, which is a broken build rather than a +guard. Widen `RATCHETED_NAMESPACES` as each remaining namespace is translated — +not before. + +Asserts both directions, because the packs have drifted both ways before: + +- every ratcheted `en` key exists in all nine other packs; +- no pack defines a ratcheted key that `en` lacks — objectui#2872 part (b) was + exactly this failure, 74 keys deep, hidden behind a component-private + fallback so English "happened to" render. + +The four outbound agent messages are excluded, since they are deliberately +absent from the eight non-gate packs; `outbound-agent-messages.test.ts` owns +that invariant and the two guards would otherwise contradict each other. + +A non-vacuity assertion pins the ratchet at >300 keys and requires every named +namespace to contribute, so a rename can't quietly reduce the whole file to a +no-op. diff --git a/packages/app-shell/src/console/ai/AiChatPage.tsx b/packages/app-shell/src/console/ai/AiChatPage.tsx index 66b7db02fb..dba0db6537 100644 --- a/packages/app-shell/src/console/ai/AiChatPage.tsx +++ b/packages/app-shell/src/console/ai/AiChatPage.tsx @@ -1602,7 +1602,7 @@ export function ChatPane({ const changesConfirmMessage = convZh ? '确认修改,应用你刚才提议的改动。' : t('console.ai.changesConfirmMessage', { - defaultValue: 'Confirm the changes — apply what you just proposed.', + defaultValue: 'Confirm — apply the change you just proposed.', }); // Verb column of the change rows. Unlike the message above these are LABELS, // so they follow the UI locale like every other label on the card. diff --git a/packages/i18n/src/__tests__/high-frequency-namespace-parity.test.ts b/packages/i18n/src/__tests__/high-frequency-namespace-parity.test.ts new file mode 100644 index 0000000000..7593a3af82 --- /dev/null +++ b/packages/i18n/src/__tests__/high-frequency-namespace-parity.test.ts @@ -0,0 +1,83 @@ +/** + * Ratchet for the namespaces backfilled in objectui#2872 part (a). + * + * `console`, `home`, `topbar` and `layout` were translated into all ten packs + * (objectui#2903) because they carry the AI console, the home screen, the top + * bar and the system navigation — the surfaces a non-English admin cannot avoid. + * + * `fallbackLng: 'en'` makes a regression here invisible: drop a key from `de` + * and the UI renders English, which looks like "not translated yet" rather than + * "we lost this". Nothing errors, and the dev-only missing-key handler never + * fires in CI. So the backfill needs a ratchet or it quietly erodes. + * + * Scope is deliberately these four namespaces and no more. The rest of the + * packs are still ~277 keys behind (`grid` 101, `gantt` 58, `dashboard` 25 and + * a long tail) and stay tracked under objectui#2872 — asserting full parity + * today would just be a red CI with no action attached to it. Widen this list + * as each namespace is completed; do not widen it ahead of the translations. + */ +import { describe, it, expect } from 'vitest'; +import { builtInLocales } from '../locales'; + +/** + * The four keys the console SENDS to the agent rather than displays. They are + * intentionally absent from the eight non-gate packs so `t()` falls through to + * its English default — see `outbound-agent-messages.test.ts` for the full + * reasoning. Excluded here so the two guards do not contradict each other. + */ +const OUTBOUND_KEYS = new Set([ + 'console.ai.planApproveMessage', + 'console.ai.planApproveDefaultsMessage', + 'console.ai.planAnswerMessage', + 'console.ai.changesConfirmMessage', +]); + +const RATCHETED_NAMESPACES = ['console', 'home', 'topbar', 'layout'] as const; + +function keyPaths(node: unknown, prefix = ''): string[] { + return node !== null && typeof node === 'object' + ? Object.entries(node as Record).flatMap(([k, v]) => + keyPaths(v, prefix ? `${prefix}.${k}` : k), + ) + : [prefix]; +} + +function ratchetedKeys(pack: unknown): Set { + return new Set( + RATCHETED_NAMESPACES.flatMap((ns) => + keyPaths((pack as Record)[ns], ns), + ).filter((k) => !OUTBOUND_KEYS.has(k)), + ); +} + +const EN = ratchetedKeys(builtInLocales.en); +const OTHER_LOCALES = Object.keys(builtInLocales).filter((l) => l !== 'en'); + +describe('high-frequency namespaces stay at parity with en (objectui#2872 part a)', () => { + it('the ratchet covers a substantial key set — not an empty assertion', () => { + // Guards against the whole file silently becoming a no-op if a namespace + // is renamed and `keyPaths` starts returning nothing. + expect(EN.size).toBeGreaterThan(300); + for (const ns of RATCHETED_NAMESPACES) { + expect([...EN].some((k) => k.startsWith(`${ns}.`)), `${ns} contributed no keys`).toBe( + true, + ); + } + }); + + it.each(OTHER_LOCALES)('%s defines every ratcheted en key', (lang) => { + const missing = [...EN].filter((k) => !ratchetedKeys(builtInLocales[lang]).has(k)).sort(); + expect( + missing, + `${lang} is missing ${missing.length} key(s) from the backfilled namespaces`, + ).toEqual([]); + }); + + it.each(OTHER_LOCALES)('%s defines no ratcheted key that en lacks', (lang) => { + // The other direction: a key added to one pack but never to `en` cannot be + // translated by anyone else and will drift. objectui#2872 part (b) was + // exactly this, 74 keys deep, hidden behind a component-private fallback. + const extra = [...ratchetedKeys(builtInLocales[lang])].filter((k) => !EN.has(k)).sort(); + expect(extra, `${lang} has ${extra.length} key(s) absent from en`).toEqual([]); + }); +}); diff --git a/packages/i18n/src/__tests__/i18n.test.ts b/packages/i18n/src/__tests__/i18n.test.ts index 596689beb8..29bcd741a1 100644 --- a/packages/i18n/src/__tests__/i18n.test.ts +++ b/packages/i18n/src/__tests__/i18n.test.ts @@ -98,37 +98,61 @@ describe('@object-ui/i18n', () => { expect(i18n.t('console.ai.nextSteps')).toBe('下一步'); }); - // Regression guard: the "开始搭建" button SENDS these two messages, and the - // cloud confirm gate (service-ai-studio confirm-gate.ts APPROVAL_RE) only - // treats Chinese text as approval when it is 确认-anchored (e.g. "确认搭建"). - // A bare "…搭建吧" silently fails the gate → the agent re-proposes and the - // button looks inert. Keep both messages matching the gate's 确认 anchor. - it('AI plan-approve messages stay anchored on the confirm gate keyword (确认)', () => { - const i18n = createI18n({ defaultLanguage: 'zh', detectBrowserLanguage: false }); - // Mirror of the cloud APPROVAL_RE Chinese clause (confirm-gate.ts). Kept - // narrow on purpose: a plain build REQUEST ("帮我搭建一个 CRM") must NOT match. - const gate = /确认[,,、]?\s*(开始|可以|并|要)?\s*(搭建|创建|生成|构建|修改|应用)|直接搭建/; - expect(i18n.t('console.ai.planApproveMessage')).toMatch(gate); - expect(i18n.t('console.ai.planApproveDefaultsMessage')).toMatch(gate); - expect('帮我搭建一个 CRM').not.toMatch(gate); - - // The granular change-confirm card (objectui#2884) sends its own message - // through the SAME gate, so it carries the same 确认 anchor. This one is - // unchanged from the string that was previously hard-coded in the - // component, so the Chinese path is byte-for-byte what the gate already - // accepted — the fix only stopped it being sent to English conversations. - expect(i18n.t('console.ai.changesConfirmMessage')).toMatch(gate); - }); - - // The English half of the same contract. The cloud APPROVAL_RE's English - // clause is not mirrored here (nothing in this repo can see it), so this - // only pins the two tokens the message is built around — enough to catch a - // careless reword that drops the approval verb entirely. - it('AI change-confirm message keeps its approval verb in English', () => { + // Regression guard: the plan card's "Build it" and the change card's + // "Confirm" buttons SEND these messages to the agent, and the cloud confirm + // gate (service-ai-studio confirm-gate.ts APPROVAL_RE) decides whether the + // text counts as approval. Text the gate does not recognise fails SILENTLY: + // the agent re-proposes instead of acting and the button just looks inert. + // + // `GATE` below is a verbatim mirror of that regex. It is duplicated across + // repo boundaries on purpose — objectui cannot import from cloud — so when + // APPROVAL_RE changes there, update this copy in the same breath. + // + // How this guard earned its keep: it originally mirrored only the CHINESE + // clause, and the English half was reduced to "starts with Confirm, contains + // apply" because nothing here could see the real pattern. That weaker check + // passed while the shipped English string ("…apply what you just proposed") + // did NOT match the gate — every English conversation's Confirm button was + // dead, and so were all eight locales that fall back to English. + const GATE = + /直接搭建|直接建|直接帮我(建|搭)|不用再?问我?|别再?问我?|无需确认|不用确认|确认[,,、]?\s*(开始|可以|并|要)?\s*(搭建|创建|生成|构建|修改|应用|执行|继续)|应用(你|您|刚才|这个?|那个?|该|上述|提议|建议)+的?\s*(改动|修改|变更|更改)|^\s*确[认定][。.!!\s]*$|just build it|go ahead and build|start building|build it now|don'?t ask|without confirmation|build it as proposed|build it with your best|apply (this|the) change/i; + + // Only the APPROVAL messages. `planAnswerMessage` is deliberately absent: it + // answers a structure question so the agent can continue, and must NOT read + // as blanket approval. + const APPROVAL_KEYS = [ + 'console.ai.planApproveMessage', + 'console.ai.planApproveDefaultsMessage', + 'console.ai.changesConfirmMessage', + ] as const; + + it.each(['zh', 'en'])( + 'every outbound approval message the %s console sends satisfies the cloud confirm gate', + (lang) => { + const i18n = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false }); + for (const key of APPROVAL_KEYS) { + const msg = i18n.t(key); + expect(msg, `${key} must not fall through to its raw key`).not.toBe(key); + expect(msg, `${lang} ${key} does not match the cloud APPROVAL_RE`).toMatch(GATE); + } + }, + ); + + it('the gate stays narrow — a plain build request is not approval', () => { + // If these ever match, the preview-first gate has been widened into + // uselessness and the mirror above is no longer worth trusting. + expect('帮我搭建一个 CRM').not.toMatch(GATE); + expect('Please build me a CRM app').not.toMatch(GATE); + expect('我不确定这样对不对').not.toMatch(GATE); + }); + + it('planAnswerMessage answers a question without reading as approval', () => { const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false }); - const msg = i18n.t('console.ai.changesConfirmMessage'); - expect(msg).toMatch(/^Confirm\b/i); - expect(msg).toMatch(/\bapply\b/i); + const msg = i18n.t('console.ai.planAnswerMessage', { + question: 'One shelf or many?', + option: 'many', + }); + expect(msg).not.toMatch(GATE); }); it('translates common keys in Japanese', () => { diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index d64ac72d29..5ee32606a6 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -1494,7 +1494,12 @@ const en = { changesConfirmed: 'Confirmed', changesConfirm: 'Confirm', changesConfirmHint: 'Reply to confirm or adjust this change.', - changesConfirmMessage: 'Confirm the changes — apply what you just proposed.', + // Wording is load-bearing: this is SENT to the agent and must satisfy the + // cloud confirm gate's English clause `apply (this|the) change` + // (service-ai-studio confirm-gate.ts APPROVAL_RE). "apply what you just + // proposed" did NOT match, so the button was inert. Singular "the change" + // so it still matches if the gate ever adds a word boundary. + changesConfirmMessage: 'Confirm — apply the change you just proposed.', changeVerb: { createObject: 'Create object', addField: 'Add field', diff --git a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx index 6d8911336f..4ce0ca3149 100644 --- a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx +++ b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx @@ -707,7 +707,9 @@ export interface ChatbotEnhancedProps extends React.HTMLAttributes( changesConfirmedLabel = 'Confirmed', changesConfirmLabel = 'Confirm', changesConfirmHintLabel = 'Reply to confirm or adjust this change.', - changesConfirmMessage = 'Confirm the changes — apply what you just proposed.', + changesConfirmMessage = 'Confirm — apply the change you just proposed.', changeVerbLabels = DEFAULT_CHANGE_VERB_LABELS, fetchPendingDraftCount, autoPublishDrafts = false,