Skip to content

Commit 0502a7c

Browse files
os-zhuangclaude
andauthored
fix(i18n): the change card's Confirm button sent text the cloud gate rejects, + parity ratchet (#2905)
* fix(i18n): the change card's Confirm button sent text the cloud gate rejects 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". The message therefore failed the gate, and failing it is silent: the agent re-proposes instead of applying and the Confirm button on the change card just looks inert. This hit English conversations AND all eight locales that fall back to English for that key. Now "Confirm — apply the change you just proposed." — singular, so it still matches if the gate ever tightens to a word boundary. The Chinese string always matched (确认修改) and is unchanged. The same literal lives in four places (locale pack, prop default, its doc comment, AiChatPage defaultValue); all four move together. Why the existing guard missed it: i18n.test.ts mirrored only the CHINESE clause of APPROVAL_RE, and the English half was weakened to "starts with Confirm, contains apply" because nothing in this repo could see the real pattern. That assertion passed against a string the gate rejected — green guard, broken feature. The mirror is now verbatim and covers both clauses, driving an it.each over every outbound approval message in zh and en. Two supporting tests keep it honest: the gate must stay narrow (a plain build request is not approval), and planAnswerMessage must NOT match, since it answers a structure question rather than granting blanket approval. Verified by running the real regex against every outbound string, and by mutation-testing the new guard against the exact string that shipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TubWYdWquVkS9dj733sDmC * test(i18n): ratchet the four backfilled namespaces so they cannot silently erode #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 #2872's P3 applied only to the namespaces that are actually complete. Full parity would fail today by ~277 keys per pack with no action attached — a broken build, not 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 the other nine packs, and no pack defines a ratcheted key en lacks (#2872 part b was exactly that, 74 keys deep, hidden behind a component-private fallback so English "happened to" render). The four outbound agent messages are excluded — they are deliberately absent from the eight non-gate packs, and outbound-agent-messages.test.ts owns that invariant; 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 cannot reduce the file to a no-op. Mutation-tested in both directions: removing topbar.offline from fr fails the first, adding an en-less key to fr fails the second. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TubWYdWquVkS9dj733sDmC --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e05f052 commit 0502a7c

7 files changed

Lines changed: 220 additions & 34 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@object-ui/i18n": patch
3+
"@object-ui/plugin-chatbot": patch
4+
"@object-ui/app-shell": patch
5+
---
6+
7+
fix(i18n): the change card's Confirm button sent text the cloud gate does not accept
8+
9+
The English `console.ai.changesConfirmMessage` was
10+
`"Confirm the changes — apply what you just proposed."`. The cloud confirm gate
11+
(`service-ai-studio` `confirm-gate.ts` `APPROVAL_RE`) recognises
12+
`apply (this|the) change`**not** "apply what". So the message failed the
13+
gate, and failing the gate is silent: the agent re-proposes instead of applying,
14+
and the Confirm button on the change card simply looks inert.
15+
16+
This affected English conversations **and all eight locales that fall back to
17+
English** for that key. It is now
18+
`"Confirm — apply the change you just proposed."` — singular "the change", so it
19+
still matches if the gate ever tightens to a word boundary. The Chinese string
20+
was always fine (`确认修改` hits the 确认-anchored clause) and is unchanged.
21+
22+
The same literal lives in four places — the locale pack, the
23+
`ChatbotEnhanced` prop default, its doc comment, and the `AiChatPage`
24+
`defaultValue` — and all four are updated together.
25+
26+
**Why the existing guard missed it.** `i18n.test.ts` mirrored only the *Chinese*
27+
clause of `APPROVAL_RE`; the English half was reduced to "starts with Confirm,
28+
contains apply" because nothing in this repo could see the real pattern. That
29+
weaker assertion passed against a string the gate rejected — the guard was
30+
green and the feature was broken.
31+
32+
The mirror is now **verbatim, both clauses**, and drives an `it.each` over every
33+
outbound approval message in both `zh` and `en`. Two supporting tests keep it
34+
honest: one asserting the gate stays narrow (a plain build request like
35+
"帮我搭建一个 CRM" must NOT read as approval), and one asserting
36+
`planAnswerMessage` does *not* match — it answers a structure question and must
37+
never read as blanket approval.
38+
39+
The mirror is duplicated across a repo boundary by necessity (objectui cannot
40+
import from cloud); the comment says so, so the next person changing
41+
`APPROVAL_RE` knows to update it here too.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@object-ui/i18n": patch
3+
---
4+
5+
test(i18n): ratchet the four backfilled namespaces so they cannot silently erode
6+
7+
objectui#2903 translated `console`, `home`, `topbar` and `layout` into all ten
8+
packs. Nothing stopped that from decaying: `fallbackLng: 'en'` means dropping a
9+
key from `de` renders English, which reads as "not translated yet" rather than
10+
"we lost this", and the missing-key handler is dev-only so CI never sees it.
11+
12+
This is objectui#2872's P3 (full parity test) applied **only to the namespaces
13+
that are actually complete**. Full parity would fail today by ~277 keys per
14+
pack with no action attached to it, which is a broken build rather than a
15+
guard. Widen `RATCHETED_NAMESPACES` as each remaining namespace is translated —
16+
not before.
17+
18+
Asserts both directions, because the packs have drifted both ways before:
19+
20+
- every ratcheted `en` key exists in all nine other packs;
21+
- no pack defines a ratcheted key that `en` lacks — objectui#2872 part (b) was
22+
exactly this failure, 74 keys deep, hidden behind a component-private
23+
fallback so English "happened to" render.
24+
25+
The four outbound agent messages are excluded, since they are deliberately
26+
absent from the eight non-gate packs; `outbound-agent-messages.test.ts` owns
27+
that invariant and the two guards would otherwise contradict each other.
28+
29+
A non-vacuity assertion pins the ratchet at >300 keys and requires every named
30+
namespace to contribute, so a rename can't quietly reduce the whole file to a
31+
no-op.

packages/app-shell/src/console/ai/AiChatPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1602,7 +1602,7 @@ export function ChatPane({
16021602
const changesConfirmMessage = convZh
16031603
? '确认修改,应用你刚才提议的改动。'
16041604
: t('console.ai.changesConfirmMessage', {
1605-
defaultValue: 'Confirm the changes — apply what you just proposed.',
1605+
defaultValue: 'Confirm — apply the change you just proposed.',
16061606
});
16071607
// Verb column of the change rows. Unlike the message above these are LABELS,
16081608
// so they follow the UI locale like every other label on the card.
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* Ratchet for the namespaces backfilled in objectui#2872 part (a).
3+
*
4+
* `console`, `home`, `topbar` and `layout` were translated into all ten packs
5+
* (objectui#2903) because they carry the AI console, the home screen, the top
6+
* bar and the system navigation — the surfaces a non-English admin cannot avoid.
7+
*
8+
* `fallbackLng: 'en'` makes a regression here invisible: drop a key from `de`
9+
* and the UI renders English, which looks like "not translated yet" rather than
10+
* "we lost this". Nothing errors, and the dev-only missing-key handler never
11+
* fires in CI. So the backfill needs a ratchet or it quietly erodes.
12+
*
13+
* Scope is deliberately these four namespaces and no more. The rest of the
14+
* packs are still ~277 keys behind (`grid` 101, `gantt` 58, `dashboard` 25 and
15+
* a long tail) and stay tracked under objectui#2872 — asserting full parity
16+
* today would just be a red CI with no action attached to it. Widen this list
17+
* as each namespace is completed; do not widen it ahead of the translations.
18+
*/
19+
import { describe, it, expect } from 'vitest';
20+
import { builtInLocales } from '../locales';
21+
22+
/**
23+
* The four keys the console SENDS to the agent rather than displays. They are
24+
* intentionally absent from the eight non-gate packs so `t()` falls through to
25+
* its English default — see `outbound-agent-messages.test.ts` for the full
26+
* reasoning. Excluded here so the two guards do not contradict each other.
27+
*/
28+
const OUTBOUND_KEYS = new Set([
29+
'console.ai.planApproveMessage',
30+
'console.ai.planApproveDefaultsMessage',
31+
'console.ai.planAnswerMessage',
32+
'console.ai.changesConfirmMessage',
33+
]);
34+
35+
const RATCHETED_NAMESPACES = ['console', 'home', 'topbar', 'layout'] as const;
36+
37+
function keyPaths(node: unknown, prefix = ''): string[] {
38+
return node !== null && typeof node === 'object'
39+
? Object.entries(node as Record<string, unknown>).flatMap(([k, v]) =>
40+
keyPaths(v, prefix ? `${prefix}.${k}` : k),
41+
)
42+
: [prefix];
43+
}
44+
45+
function ratchetedKeys(pack: unknown): Set<string> {
46+
return new Set(
47+
RATCHETED_NAMESPACES.flatMap((ns) =>
48+
keyPaths((pack as Record<string, unknown>)[ns], ns),
49+
).filter((k) => !OUTBOUND_KEYS.has(k)),
50+
);
51+
}
52+
53+
const EN = ratchetedKeys(builtInLocales.en);
54+
const OTHER_LOCALES = Object.keys(builtInLocales).filter((l) => l !== 'en');
55+
56+
describe('high-frequency namespaces stay at parity with en (objectui#2872 part a)', () => {
57+
it('the ratchet covers a substantial key set — not an empty assertion', () => {
58+
// Guards against the whole file silently becoming a no-op if a namespace
59+
// is renamed and `keyPaths` starts returning nothing.
60+
expect(EN.size).toBeGreaterThan(300);
61+
for (const ns of RATCHETED_NAMESPACES) {
62+
expect([...EN].some((k) => k.startsWith(`${ns}.`)), `${ns} contributed no keys`).toBe(
63+
true,
64+
);
65+
}
66+
});
67+
68+
it.each(OTHER_LOCALES)('%s defines every ratcheted en key', (lang) => {
69+
const missing = [...EN].filter((k) => !ratchetedKeys(builtInLocales[lang]).has(k)).sort();
70+
expect(
71+
missing,
72+
`${lang} is missing ${missing.length} key(s) from the backfilled namespaces`,
73+
).toEqual([]);
74+
});
75+
76+
it.each(OTHER_LOCALES)('%s defines no ratcheted key that en lacks', (lang) => {
77+
// The other direction: a key added to one pack but never to `en` cannot be
78+
// translated by anyone else and will drift. objectui#2872 part (b) was
79+
// exactly this, 74 keys deep, hidden behind a component-private fallback.
80+
const extra = [...ratchetedKeys(builtInLocales[lang])].filter((k) => !EN.has(k)).sort();
81+
expect(extra, `${lang} has ${extra.length} key(s) absent from en`).toEqual([]);
82+
});
83+
});

packages/i18n/src/__tests__/i18n.test.ts

Lines changed: 54 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -98,37 +98,61 @@ describe('@object-ui/i18n', () => {
9898
expect(i18n.t('console.ai.nextSteps')).toBe('下一步');
9999
});
100100

101-
// Regression guard: the "开始搭建" button SENDS these two messages, and the
102-
// cloud confirm gate (service-ai-studio confirm-gate.ts APPROVAL_RE) only
103-
// treats Chinese text as approval when it is 确认-anchored (e.g. "确认搭建").
104-
// A bare "…搭建吧" silently fails the gate → the agent re-proposes and the
105-
// button looks inert. Keep both messages matching the gate's 确认 anchor.
106-
it('AI plan-approve messages stay anchored on the confirm gate keyword (确认)', () => {
107-
const i18n = createI18n({ defaultLanguage: 'zh', detectBrowserLanguage: false });
108-
// Mirror of the cloud APPROVAL_RE Chinese clause (confirm-gate.ts). Kept
109-
// narrow on purpose: a plain build REQUEST ("帮我搭建一个 CRM") must NOT match.
110-
const gate = /[,]?\s*(|||)?\s*(|||||)|/;
111-
expect(i18n.t('console.ai.planApproveMessage')).toMatch(gate);
112-
expect(i18n.t('console.ai.planApproveDefaultsMessage')).toMatch(gate);
113-
expect('帮我搭建一个 CRM').not.toMatch(gate);
114-
115-
// The granular change-confirm card (objectui#2884) sends its own message
116-
// through the SAME gate, so it carries the same 确认 anchor. This one is
117-
// unchanged from the string that was previously hard-coded in the
118-
// component, so the Chinese path is byte-for-byte what the gate already
119-
// accepted — the fix only stopped it being sent to English conversations.
120-
expect(i18n.t('console.ai.changesConfirmMessage')).toMatch(gate);
121-
});
122-
123-
// The English half of the same contract. The cloud APPROVAL_RE's English
124-
// clause is not mirrored here (nothing in this repo can see it), so this
125-
// only pins the two tokens the message is built around — enough to catch a
126-
// careless reword that drops the approval verb entirely.
127-
it('AI change-confirm message keeps its approval verb in English', () => {
101+
// Regression guard: the plan card's "Build it" and the change card's
102+
// "Confirm" buttons SEND these messages to the agent, and the cloud confirm
103+
// gate (service-ai-studio confirm-gate.ts APPROVAL_RE) decides whether the
104+
// text counts as approval. Text the gate does not recognise fails SILENTLY:
105+
// the agent re-proposes instead of acting and the button just looks inert.
106+
//
107+
// `GATE` below is a verbatim mirror of that regex. It is duplicated across
108+
// repo boundaries on purpose — objectui cannot import from cloud — so when
109+
// APPROVAL_RE changes there, update this copy in the same breath.
110+
//
111+
// How this guard earned its keep: it originally mirrored only the CHINESE
112+
// clause, and the English half was reduced to "starts with Confirm, contains
113+
// apply" because nothing here could see the real pattern. That weaker check
114+
// passed while the shipped English string ("…apply what you just proposed")
115+
// did NOT match the gate — every English conversation's Confirm button was
116+
// dead, and so were all eight locales that fall back to English.
117+
const GATE =
118+
/||(|)|??|??|||[,]?\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;
119+
120+
// Only the APPROVAL messages. `planAnswerMessage` is deliberately absent: it
121+
// answers a structure question so the agent can continue, and must NOT read
122+
// as blanket approval.
123+
const APPROVAL_KEYS = [
124+
'console.ai.planApproveMessage',
125+
'console.ai.planApproveDefaultsMessage',
126+
'console.ai.changesConfirmMessage',
127+
] as const;
128+
129+
it.each(['zh', 'en'])(
130+
'every outbound approval message the %s console sends satisfies the cloud confirm gate',
131+
(lang) => {
132+
const i18n = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false });
133+
for (const key of APPROVAL_KEYS) {
134+
const msg = i18n.t(key);
135+
expect(msg, `${key} must not fall through to its raw key`).not.toBe(key);
136+
expect(msg, `${lang} ${key} does not match the cloud APPROVAL_RE`).toMatch(GATE);
137+
}
138+
},
139+
);
140+
141+
it('the gate stays narrow — a plain build request is not approval', () => {
142+
// If these ever match, the preview-first gate has been widened into
143+
// uselessness and the mirror above is no longer worth trusting.
144+
expect('帮我搭建一个 CRM').not.toMatch(GATE);
145+
expect('Please build me a CRM app').not.toMatch(GATE);
146+
expect('我不确定这样对不对').not.toMatch(GATE);
147+
});
148+
149+
it('planAnswerMessage answers a question without reading as approval', () => {
128150
const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
129-
const msg = i18n.t('console.ai.changesConfirmMessage');
130-
expect(msg).toMatch(/^Confirm\b/i);
131-
expect(msg).toMatch(/\bapply\b/i);
151+
const msg = i18n.t('console.ai.planAnswerMessage', {
152+
question: 'One shelf or many?',
153+
option: 'many',
154+
});
155+
expect(msg).not.toMatch(GATE);
132156
});
133157

134158
it('translates common keys in Japanese', () => {

packages/i18n/src/locales/en.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1494,7 +1494,12 @@ const en = {
14941494
changesConfirmed: 'Confirmed',
14951495
changesConfirm: 'Confirm',
14961496
changesConfirmHint: 'Reply to confirm or adjust this change.',
1497-
changesConfirmMessage: 'Confirm the changes — apply what you just proposed.',
1497+
// Wording is load-bearing: this is SENT to the agent and must satisfy the
1498+
// cloud confirm gate's English clause `apply (this|the) change`
1499+
// (service-ai-studio confirm-gate.ts APPROVAL_RE). "apply what you just
1500+
// proposed" did NOT match, so the button was inert. Singular "the change"
1501+
// so it still matches if the gate ever adds a word boundary.
1502+
changesConfirmMessage: 'Confirm — apply the change you just proposed.',
14981503
changeVerb: {
14991504
createObject: 'Create object',
15001505
addField: 'Add field',

packages/plugin-chatbot/src/ChatbotEnhanced.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -707,7 +707,9 @@ export interface ChatbotEnhancedProps extends React.HTMLAttributes<HTMLDivElemen
707707
* `planApproveMessage` this is OUTBOUND text, so the console picks it by the
708708
* language of the CONVERSATION, not of the UI — sending Chinese from an
709709
* English session would flip the agent's reply language (objectui#2884).
710-
* Default "Confirm the changes — apply what you just proposed."
710+
* The wording must satisfy the cloud confirm gate's approval pattern or the
711+
* agent re-proposes and the button looks inert.
712+
* Default "Confirm — apply the change you just proposed."
711713
*/
712714
changesConfirmMessage?: string;
713715
/**
@@ -1263,7 +1265,7 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
12631265
changesConfirmedLabel = 'Confirmed',
12641266
changesConfirmLabel = 'Confirm',
12651267
changesConfirmHintLabel = 'Reply to confirm or adjust this change.',
1266-
changesConfirmMessage = 'Confirm the changes — apply what you just proposed.',
1268+
changesConfirmMessage = 'Confirm — apply the change you just proposed.',
12671269
changeVerbLabels = DEFAULT_CHANGE_VERB_LABELS,
12681270
fetchPendingDraftCount,
12691271
autoPublishDrafts = false,

0 commit comments

Comments
 (0)