Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/confirm-gate-english-clause.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 31 additions & 0 deletions .changeset/high-frequency-parity-ratchet.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/app-shell/src/console/ai/AiChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>).flatMap(([k, v]) =>
keyPaths(v, prefix ? `${prefix}.${k}` : k),
)
: [prefix];
}

function ratchetedKeys(pack: unknown): Set<string> {
return new Set(
RATCHETED_NAMESPACES.flatMap((ns) =>
keyPaths((pack as Record<string, unknown>)[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([]);
});
});
84 changes: 54 additions & 30 deletions packages/i18n/src/__tests__/i18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
7 changes: 6 additions & 1 deletion packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 4 additions & 2 deletions packages/plugin-chatbot/src/ChatbotEnhanced.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -707,7 +707,9 @@ export interface ChatbotEnhancedProps extends React.HTMLAttributes<HTMLDivElemen
* `planApproveMessage` this is OUTBOUND text, so the console picks it by the
* language of the CONVERSATION, not of the UI — sending Chinese from an
* English session would flip the agent's reply language (objectui#2884).
* Default "Confirm the changes — apply what you just proposed."
* The wording must satisfy the cloud confirm gate's approval pattern or the
* agent re-proposes and the button looks inert.
* Default "Confirm — apply the change you just proposed."
*/
changesConfirmMessage?: string;
/**
Expand Down Expand Up @@ -1263,7 +1265,7 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
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,
Expand Down
Loading