From db16842ac984ae106d2df11322e278a59095246a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 02:34:35 +0000 Subject: [PATCH] =?UTF-8?q?fix(app-shell):=20AI=20=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E7=9A=84=E5=87=BA=E7=AB=99=E6=96=87=E6=9C=AC=E9=9A=8F=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E8=AF=AD=E8=A8=80=E5=8F=96=E5=80=BC,=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=AF=BB=20UI=20=E5=8C=85=20(#3896)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #772 / #2884 立下的规则写在 `AiChatPage` 的门上:出站文本随**会话**语言,渲染 标签随 UI 语言。规则只实现了一半 —— 三条出站文本写作 `convZh ? '<中文字面量>' : t('console.ai.…')`,而 `t()` 读的是 **UI 包**,于是 「非中文」那一支答的是 UI 语言、不是英文;第四条 `planAnswerMessage` 连门都没过。 两个方向各一个已实测后果: - **zh 控制台 + 英文会话发出中文。** zh 包定义了全部四个 key,所以 `t()` 是命中 而非落到英文 defaultValue:点 Build it,进英文线程的是 `确认,开始搭建。`。cloud 确认门(`service-ai-studio` `confirm-gate.ts` `APPROVAL_RE`)两种语言都认,构建 照走,agent 随后把整条线程转成中文 —— 即 #2884 的症状、触发方向相反。 - **一键回答芯片两个方向都发 UI 语言。** 完全未门控:中文会话里发出 `For "…", go with: …`(#772 开头那句抱怨的字面重演),zh 控制台下的英文会话里 发出中文句子。 四个站点现在统一走一个取值器 `outboundAgentText.ts`:中文会话取 `zh` 包该 key, 其余会话取 `en` 包该 key,永不读 UI 包。三个控制台 AI 界面(`/ai` 页、chat dock、 Studio copilot)都挂同一个 `ChatPane`,一并生效。 直接读两个包而不用 `t(key, { lng })` 是刻意的:i18next 的答案取决于宿主 app 装了 哪些 bundle 以及 `fallbackLng`,而 zh 查询静默回落到 en 正是上面那个错语言 bug。 取值器内的兜底表按语言分,并被钉成与两个包逐字节一致 —— 万一某个包不再定义该 key, 中文会话兜底到中文,绝不兜到英文。 标签未动:仍随 UI 语言。#3837 那条「`*Label` 漂回会话门就红」的钉子现在同时守住 反向 —— 出站 `*Message` 被重新塞回 `t()` 也会红。 Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt --- ...tbound-agent-conversation-language-3896.md | 46 ++++++++ .../app-shell/src/console/ai/AiChatPage.tsx | 38 +++--- .../AiChatPage.planCardLocale.test.tsx | 104 ++++++++++++++++- .../ai/__tests__/outboundAgentText.test.ts | 98 ++++++++++++++++ .../src/console/ai/outboundAgentText.ts | 109 ++++++++++++++++++ .../__tests__/all-locales-key-parity.test.ts | 11 +- .../__tests__/console-namespace-3546.test.tsx | 37 ++++-- .../__tests__/outbound-agent-messages.test.ts | 42 ++++--- 8 files changed, 435 insertions(+), 50 deletions(-) create mode 100644 .changeset/outbound-agent-conversation-language-3896.md create mode 100644 packages/app-shell/src/console/ai/__tests__/outboundAgentText.test.ts create mode 100644 packages/app-shell/src/console/ai/outboundAgentText.ts diff --git a/.changeset/outbound-agent-conversation-language-3896.md b/.changeset/outbound-agent-conversation-language-3896.md new file mode 100644 index 0000000000..96ea865971 --- /dev/null +++ b/.changeset/outbound-agent-conversation-language-3896.md @@ -0,0 +1,46 @@ +--- +"@object-ui/app-shell": patch +--- + +The AI plan / confirm cards send the agent text in the CONVERSATION's language, not the console UI's (objectui#3896) + +#772 / #2884 established the rule and `AiChatPage` states it at the gate: +outbound text follows the conversation, rendered labels follow the UI locale. +Only half of it was implemented. Three of the four outbound messages read + + convZh ? '' : t('console.ai.…') + +and `t()` is the **UI pack**, so the "not Chinese" branch answered with the UI +locale rather than with English. The fourth, `planAnswerMessage`, had no gate at +all. + +Two measured consequences, one in each direction: + +- **A zh console holding an English conversation sent Chinese.** The `zh` pack + defines all four keys, so the `t()` lookup HIT instead of falling through to + its English default: clicking "Build it" put `确认,开始搭建。` into an English + thread. Cloud's confirm gate (`service-ai-studio` `confirm-gate.ts` + `APPROVAL_RE`) recognises both languages, so the build ran — and the agent + switched the rest of the thread to Chinese. That is objectui#2884's symptom + with the trigger reversed. +- **The answer chip sent the UI locale in both directions.** Ungated, it put + `For "…", go with: …` into a Chinese thread — objectui#772's opening complaint + verbatim — and a Chinese sentence into an English one under a zh console. + +All four sites now go through one resolver, +`packages/app-shell/src/console/ai/outboundAgentText.ts`: a Chinese conversation +gets the `zh` pack's value, every other conversation gets the `en` pack's value, +and the UI pack is never consulted. The three console AI surfaces (`/ai` page, +chat dock, Studio copilot) all mount the same `ChatPane`, so all three change +together. + +Reading the two packs directly rather than `t(key, { lng })` is deliberate: an +i18next lookup's answer depends on which bundles the host app loaded and on +`fallbackLng`, and a `zh` lookup silently falling back to `en` is exactly the +wrong-language bug above. The per-language fallback table in the resolver is +pinned byte-identical to both packs, so a Chinese conversation falls back to +Chinese — never to English — if a pack ever stops defining a key. + +Labels are untouched: they still follow the UI locale, and the pin that fails +when a `*Label` drifts into the conversation gate (objectui#3837) now also fails +when an outbound `*Message` is read back out of the UI pack. diff --git a/packages/app-shell/src/console/ai/AiChatPage.tsx b/packages/app-shell/src/console/ai/AiChatPage.tsx index 3153822c05..a9c8a68b93 100644 --- a/packages/app-shell/src/console/ai/AiChatPage.tsx +++ b/packages/app-shell/src/console/ai/AiChatPage.tsx @@ -100,6 +100,7 @@ import { LiveCanvas } from './LiveCanvas'; import { artifactStudioPath } from './artifactStudioPath'; import { BuildDebugDrawer } from './BuildDebugDrawer'; import { isConversationZh } from './conversationLanguage'; +import { resolveOutboundAgentText, type OutboundAgentTextKey } from './outboundAgentText'; const DEFAULT_AI_PATH = '/api/v1/ai'; @@ -1582,8 +1583,7 @@ export function ChatPane({ // language, not the console UI locale: a Chinese thread under an English UI // was sending "Looks good — build it as proposed." into its own chat. The // gate (service-ai-studio) accepts both languages, so this is a cosmetic — - // but jarring — mismatch. Override the sent strings to Chinese when the - // conversation is Chinese; button LABELS stay on the UI locale. + // but jarring — mismatch. Button LABELS stay on the UI locale. // // That last clause is load-bearing: `planBuildingLabel` had drifted into this // gate (#2632) and shipped the plan card's only Chinese word to English-UI @@ -1591,26 +1591,28 @@ export function ChatPane({ // zh conversations (#3837). Nothing but OUTBOUND message text belongs below — // a pin in `packages/i18n/src/__tests__/console-namespace-3546.test.tsx` fails // if a `*Label` rejoins the gate. + // + // #3896 — the OTHER half of that rule. These sites used to read + // `convZh ? '' : t(key)`, and `t()` is the UI pack, so a zh console + // holding an English conversation sent Chinese into an English thread, and the + // ungated `planAnswerMessage` sent the UI locale in both directions. + // `outboundAgentText` resolves all four from the `zh`/`en` packs by + // conversation language and never consults the UI pack. const convZh = useMemo( () => isConversationZh(messages as ChatMessage[]) || isConversationZh(initialMessages), [messages, initialMessages], ); - const planApproveMessage = convZh - ? '确认,开始搭建。' - : t('console.ai.planApproveMessage', { defaultValue: 'Looks good — build it as proposed.' }); - const planApproveDefaultsMessage = convZh - ? '确认搭建,未决问题按你的合理假设和默认处理。' - : t('console.ai.planApproveDefaultsMessage', { - defaultValue: 'Build it with your best assumptions; use sensible defaults for the open questions.', - }); + const outboundText = useCallback( + (key: OutboundAgentTextKey, vars?: Readonly>) => + resolveOutboundAgentText(convZh, key, vars), + [convZh], + ); + const planApproveMessage = outboundText('planApproveMessage'); + const planApproveDefaultsMessage = outboundText('planApproveDefaultsMessage'); // Same rule for the granular change-confirm card. It used to send a hard-coded // Chinese sentence regardless of the conversation, so an English user clicking // Confirm flipped the agent into Chinese for the rest of the thread (#2884). - const changesConfirmMessage = convZh - ? '确认修改,应用你刚才提议的改动。' - : t('console.ai.changesConfirmMessage', { - defaultValue: 'Confirm — apply the change you just proposed.', - }); + const changesConfirmMessage = outboundText('changesConfirmMessage'); // 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. const changeVerbLabels = useMemo( @@ -2213,11 +2215,7 @@ export function ChatPane({ changesConfirmMessage={changesConfirmMessage} changeVerbLabels={changeVerbLabels} planAnswerMessage={(question, option) => - t('console.ai.planAnswerMessage', { - question, - option, - defaultValue: 'For "{{question}}", go with: {{option}}.', - }) + outboundText('planAnswerMessage', { question, option }) } // Self-use "magic moment": when the plan enables it, publish the drafted // app automatically the moment the agent finishes — no manual click; the diff --git a/packages/app-shell/src/console/ai/__tests__/AiChatPage.planCardLocale.test.tsx b/packages/app-shell/src/console/ai/__tests__/AiChatPage.planCardLocale.test.tsx index 309c499809..c7ea206261 100644 --- a/packages/app-shell/src/console/ai/__tests__/AiChatPage.planCardLocale.test.tsx +++ b/packages/app-shell/src/console/ai/__tests__/AiChatPage.planCardLocale.test.tsx @@ -169,11 +169,11 @@ describe('#3837 — plan-card labels follow the UI locale, sent messages follow // An English thread is read under a NON-Chinese, non-English console on // purpose: `outbound-agent-messages.test.ts` guarantees no pack but `en` and - // `zh` defines these keys, so `de` proves the English default — the wording - // the cloud gate matches — is what a non-Chinese thread sends. (A `zh` - // console is the one combination that does not hold; measured while writing - // this test and filed separately — it is the sent-message half of the gate, - // not the label half this PR narrows.) + // `zh` defines these keys, so `de` proves the English wording the cloud gate + // matches is what a non-Chinese thread sends. (When this was written a `zh` + // console was the one combination that did NOT hold — the non-Chinese branch + // read the UI pack. That was filed as #3896 and fixed there; the full + // matrix, including that combination, is the describe block below.) const enUnderDe = renderPane('de', EN_THREAD); expect(enUnderDe.planApproveMessage).toBe('Looks good — build it as proposed.'); expect(enUnderDe.planApproveDefaultsMessage).toBe( @@ -182,3 +182,97 @@ describe('#3837 — plan-card labels follow the UI locale, sent messages follow expect(enUnderDe.changesConfirmMessage).toBe('Confirm — apply the change you just proposed.'); }); }); + +/** + * The send half, all four sites, both directions (objectui#3896). + * + * Before this: the three gated messages fell back to `t()` — the UI PACK — for a + * non-Chinese conversation, and `planAnswerMessage` had no gate at all. So the + * two rows that discriminate are the two the UI locale and the conversation + * disagree on: + * + * - `zh` UI + English thread — sent `确认,开始搭建。` into an English thread + * (the zh pack defines all four keys, so `t()` HIT instead of falling + * through). The cloud gate matched it, the build ran, and the agent flipped + * the thread to Chinese: #2884's symptom, trigger reversed. + * - non-`zh` UI + Chinese thread — `planAnswerMessage` sent `For "…", go + * with: …` into a Chinese thread, which is #772's opening complaint verbatim. + * + * The matching-language rows are the invariance half: they read the same either + * way and are here so a future "simplification" that re-reads the UI pack cannot + * pass by getting the agreeing cases right. + */ +describe('#3896 — outbound text follows the CONVERSATION, in both directions', () => { + const ZH_OUTBOUND = { + planApproveMessage: '确认,开始搭建。', + planApproveDefaultsMessage: '确认搭建,未决问题按你的合理假设和默认处理。', + changesConfirmMessage: '确认修改,应用你刚才提议的改动。', + }; + const EN_OUTBOUND = { + planApproveMessage: 'Looks good — build it as proposed.', + planApproveDefaultsMessage: + 'Build it with your best assumptions; use sensible defaults for the open questions.', + changesConfirmMessage: 'Confirm — apply the change you just proposed.', + }; + + /** The three string-valued outbound props, as one comparable object. */ + function sent(props: Record) { + return { + planApproveMessage: props.planApproveMessage, + planApproveDefaultsMessage: props.planApproveDefaultsMessage, + changesConfirmMessage: props.changesConfirmMessage, + }; + } + + /** The fourth site is a function — the answer chip builds its text per option. */ + function answerChip(props: Record, question: string, option: string): string { + return (props.planAnswerMessage as (q: string, o: string) => string)(question, option); + } + + // `de` rides along as the third locale: it defines none of the four keys, so it + // proves the resolver is not quietly leaning on a UI-pack lookup that happens + // to agree. + it.each(['zh', 'en', 'de'])( + '%s UI + English thread: all four are English, so the gate reads English', + (uiLocale) => { + const props = renderPane(uiLocale, EN_THREAD); + expect(sent(props)).toEqual(EN_OUTBOUND); + expect(answerChip(props, 'One shelf or many?', 'many')).toBe( + 'For "One shelf or many?", go with: many.', + ); + // Nothing Chinese leaves the console for an English thread — the class of + // defect, not just the four strings. + for (const value of Object.values(sent(props))) { + expect(CJK.test(String(value)), `outbound Chinese under ${uiLocale} UI`).toBe(false); + } + }, + ); + + it.each(['en', 'zh', 'de'])( + '%s UI + Chinese thread: all four are Chinese, planAnswerMessage included', + (uiLocale) => { + const props = renderPane(uiLocale, ZH_THREAD); + expect(sent(props)).toEqual(ZH_OUTBOUND); + // This assertion is the whole of consequence B: the chip was ungated, so + // under an `en`/`de` console it sent English into a Chinese thread. + expect(answerChip(props, '一个货架还是多个?', '多个')).toBe( + '关于「一个货架还是多个?」,我选择「多个」。', + ); + }, + ); + + it('the answer chip does not read as blanket approval in either language', () => { + // `planAnswerMessage` answers a structure question; the cloud gate must NOT + // treat it as approval (the `APPROVAL_RE` mirror in + // `packages/i18n/src/__tests__/i18n.test.ts` owns that check on the pack + // values). Here: it stays distinct from the approval text it sits next to. + const zhProps = renderPane('en', ZH_THREAD); + expect(answerChip(zhProps, '一个货架还是多个?', '多个')).not.toBe( + ZH_OUTBOUND.planApproveMessage, + ); + const enProps = renderPane('zh', EN_THREAD); + expect(answerChip(enProps, 'One shelf or many?', 'many')).not.toBe( + EN_OUTBOUND.planApproveMessage, + ); + }); +}); diff --git a/packages/app-shell/src/console/ai/__tests__/outboundAgentText.test.ts b/packages/app-shell/src/console/ai/__tests__/outboundAgentText.test.ts new file mode 100644 index 0000000000..5f1cc18ede --- /dev/null +++ b/packages/app-shell/src/console/ai/__tests__/outboundAgentText.test.ts @@ -0,0 +1,98 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `resolveOutboundAgentText` — the outbound half of the plan/confirm-card + * language gate (objectui#3896). + * + * The invariant: what a card SENDS is a function of the CONVERSATION's language + * and nothing else. There is deliberately no `I18nProvider` in this file — a UI + * locale cannot even be expressed here, which is the strongest statement of the + * rule the unit level can make. `AiChatPage.planCardLocale.test.tsx` then pins + * the same four strings through a real provider, where a UI locale exists and + * could still leak in. + * + * Gate conformance is NOT re-asserted here: the cloud `APPROVAL_RE` mirror lives + * in `packages/i18n/src/__tests__/i18n.test.ts` and pins the `zh`/`en` PACK + * values against it. This file pins that the resolver hands back exactly those + * pack values, which is the other link of that chain. + */ + +import { describe, it, expect } from 'vitest'; +import { en, zh } from '@object-ui/i18n'; +import { + OUTBOUND_GATE_TEXT, + resolveOutboundAgentText, + type OutboundAgentTextKey, +} from '../outboundAgentText'; + +const KEYS = [ + 'planApproveMessage', + 'planApproveDefaultsMessage', + 'planAnswerMessage', + 'changesConfirmMessage', +] as const; + +function packValue(pack: unknown, key: OutboundAgentTextKey): unknown { + return (pack as { console: { ai: Record } }).console.ai[key]; +} + +describe('outbound agent text follows the conversation, never the UI locale (#3896)', () => { + it('covers every outbound key the i18n guard owns — no silent shrink', () => { + // Mirrors OUTBOUND_KEYS in packages/i18n/src/__tests__/outbound-agent-messages.test.ts. + expect(Object.keys(OUTBOUND_GATE_TEXT).sort()).toEqual([...KEYS].sort()); + }); + + it.each(KEYS)('a Chinese conversation gets the zh pack value for %s', (key) => { + expect(resolveOutboundAgentText(true, key)).toBe(packValue(zh, key)); + }); + + it.each(KEYS)('a non-Chinese conversation gets the en pack value for %s', (key) => { + // The defect: this branch used to be `t(key)`, i.e. the UI pack — so a zh + // console answered a non-Chinese conversation in Chinese. + expect(resolveOutboundAgentText(false, key)).toBe(packValue(en, key)); + }); + + it.each(KEYS)('the two answers for %s really differ, so the pin can discriminate', (key) => { + expect(resolveOutboundAgentText(true, key)).not.toBe(resolveOutboundAgentText(false, key)); + }); + + it.each(KEYS)('the last-resort wording for %s mirrors both packs byte for byte', (key) => { + // If a pack ever stops defining the key the resolver falls back to this + // table, and a Chinese conversation must fall back to CHINESE. Pinning the + // mirror is what keeps that fallback gate-correct instead of stale. + expect(OUTBOUND_GATE_TEXT[key].zh).toBe(packValue(zh, key)); + expect(OUTBOUND_GATE_TEXT[key].en).toBe(packValue(en, key)); + }); + + describe('planAnswerMessage placeholders', () => { + it('fills question and option in a Chinese conversation', () => { + expect( + resolveOutboundAgentText(true, 'planAnswerMessage', { + question: '一个货架还是多个?', + option: '多个', + }), + ).toBe('关于「一个货架还是多个?」,我选择「多个」。'); + }); + + it('fills question and option in an English conversation', () => { + expect( + resolveOutboundAgentText(false, 'planAnswerMessage', { + question: 'One shelf or many?', + option: 'many', + }), + ).toBe('For "One shelf or many?", go with: many.'); + }); + + it('leaves an unsupplied placeholder visible instead of blanking it', () => { + // A silently-dropped placeholder would send a truncated question to the + // agent; leaving the token makes the bug legible in the thread itself. + expect(resolveOutboundAgentText(false, 'planAnswerMessage', { question: 'Q' })).toBe( + 'For "Q", go with: {{option}}.', + ); + }); + + it('does not interpolate when no variables are supplied', () => { + expect(resolveOutboundAgentText(false, 'planAnswerMessage')).toBe(packValue(en, 'planAnswerMessage')); + }); + }); +}); diff --git a/packages/app-shell/src/console/ai/outboundAgentText.ts b/packages/app-shell/src/console/ai/outboundAgentText.ts new file mode 100644 index 0000000000..36f3eeee21 --- /dev/null +++ b/packages/app-shell/src/console/ai/outboundAgentText.ts @@ -0,0 +1,109 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The text a plan / confirm card SENDS to the agent, resolved by the language of +// the CONVERSATION (objectui#3896). +// +// #772 / #2884 established the rule and `AiChatPage` states it at the gate: +// outbound text follows the conversation, rendered labels follow the console UI +// locale. The gate that reads this text lives in cloud +// (`service-ai-studio` `confirm-gate.ts` `APPROVAL_RE`) and recognises exactly +// two languages — Chinese and English. +// +// The rule was only half implemented. Each site was +// `convZh ? '' : t('console.ai.…')`, and `t()` reads the UI +// PACK — so the non-Chinese branch answered with the UI locale, not with +// English. A zh console holding an English conversation therefore sent +// `确认,开始搭建。` into an English thread (the zh pack defines all four keys): +// the gate matched, the build ran, and the agent switched the whole thread to +// Chinese — #2884's symptom with the trigger reversed. `planAnswerMessage` was +// worse: never gated at all, so it sent the UI locale's value in both +// directions. +// +// Hence this module: ONE resolver, four sites, both directions, and the UI pack +// is never consulted. Chinese conversation -> the `zh` pack's value; anything +// else -> the `en` pack's value. Reading the built-in packs directly (instead of +// `t(key, { lng })`) is deliberate: an i18next lookup's answer depends on which +// bundles the host app happened to load and on `fallbackLng`, and a zh lookup +// that falls back to `en` is precisely the wrong-language bug above. Here the +// source of each string is declared, not resolved at runtime. + +import { en as enPack, zh as zhPack } from '@object-ui/i18n'; + +/** + * The `console.ai.*` keys that are outbound message TEXT rather than labels. + * Mirrors `OUTBOUND_KEYS` in `packages/i18n/src/__tests__/outbound-agent-messages.test.ts`, + * which owns the invariant that only the `en` and `zh` packs define them. + */ +export type OutboundAgentTextKey = + | 'planApproveMessage' + | 'planApproveDefaultsMessage' + | 'planAnswerMessage' + | 'changesConfirmMessage'; + +/** + * Last-resort wording per gate language, used only if a pack stops defining the + * key. Same convention as every `t(key, { defaultValue })` call site in this + * repo — the English default is inline there too — except that a Chinese + * conversation falls back to CHINESE. Falling back to English would re-create + * the defect this module exists to fix, silently. + * + * `outboundAgentText.test.ts` pins each entry byte-identical to its pack value, + * so the mirror cannot drift. + */ +const GATE_TEXT: Record> = { + planApproveMessage: { + zh: '确认,开始搭建。', + en: 'Looks good — build it as proposed.', + }, + planApproveDefaultsMessage: { + zh: '确认搭建,未决问题按你的合理假设和默认处理。', + en: 'Build it with your best assumptions; use sensible defaults for the open questions.', + }, + planAnswerMessage: { + zh: '关于「{{question}}」,我选择「{{option}}」。', + en: 'For "{{question}}", go with: {{option}}.', + }, + changesConfirmMessage: { + zh: '确认修改,应用你刚才提议的改动。', + en: 'Confirm — apply the change you just proposed.', + }, +}; + +/** Read `console.ai.` out of a locale pack, or `undefined` if absent. */ +function packText(pack: unknown, key: OutboundAgentTextKey): string | undefined { + const ai = (pack as { console?: { ai?: Record } } | undefined)?.console?.ai; + const value = ai?.[key]; + return typeof value === 'string' && value.trim() !== '' ? value : undefined; +} + +/** + * Fill `{{name}}` placeholders. i18next's default interpolation syntax, applied + * to a pack value we read ourselves; unknown placeholders are left intact so a + * mis-named variable shows up in the sent text instead of silently vanishing. + */ +function interpolate(template: string, vars?: Readonly>): string { + if (!vars) return template; + return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (whole, name: string) => + Object.prototype.hasOwnProperty.call(vars, name) ? vars[name] : whole, + ); +} + +/** + * Resolve one outbound message in the CONVERSATION's language. + * + * @param conversationIsZh `isConversationZh(...)` for the thread being sent into. + * @param key which outbound message. + * @param vars values for the message's `{{placeholders}}`. + */ +export function resolveOutboundAgentText( + conversationIsZh: boolean, + key: OutboundAgentTextKey, + vars?: Readonly>, +): string { + const lang = conversationIsZh ? 'zh' : 'en'; + const template = packText(conversationIsZh ? zhPack : enPack, key) ?? GATE_TEXT[key][lang]; + return interpolate(template, vars); +} + +/** Exposed for the pin that keeps {@link GATE_TEXT} and the packs identical. */ +export const OUTBOUND_GATE_TEXT = GATE_TEXT; diff --git a/packages/i18n/src/__tests__/all-locales-key-parity.test.ts b/packages/i18n/src/__tests__/all-locales-key-parity.test.ts index 669b06783b..2c9039f15a 100644 --- a/packages/i18n/src/__tests__/all-locales-key-parity.test.ts +++ b/packages/i18n/src/__tests__/all-locales-key-parity.test.ts @@ -44,11 +44,12 @@ import { builtInLocales } from '../locales'; /** * Text the console SENDS to the agent rather than displays. These are absent - * from the eight non-gate packs ON PURPOSE, so `t()` falls through to its - * English default and the cloud confirm gate keeps recognising the message — - * see `outbound-agent-messages.test.ts`, which owns that invariant and asserts - * it in both directions. Excluded here so the two guards cannot contradict - * each other. + * from the eight non-gate packs ON PURPOSE: the console resolves them from the + * `en`/`zh` packs by the CONVERSATION's language (objectui#3896), so a value in + * any other pack is unreachable, and the cloud confirm gate only recognises + * those two languages anyway — see `outbound-agent-messages.test.ts`, which owns + * that invariant and asserts it in both directions. Excluded here so the two + * guards cannot contradict each other. */ const OUTBOUND_KEYS = new Set([ 'console.ai.planApproveMessage', diff --git a/packages/i18n/src/__tests__/console-namespace-3546.test.tsx b/packages/i18n/src/__tests__/console-namespace-3546.test.tsx index aebe4749e5..d09235b305 100644 --- a/packages/i18n/src/__tests__/console-namespace-3546.test.tsx +++ b/packages/i18n/src/__tests__/console-namespace-3546.test.tsx @@ -318,17 +318,40 @@ describe('objectui#3546 slice four — the console namespace', () => { // those two languages (see outbound-agent-messages.test.ts) — never anything // RENDERED. Whole-line comments are stripped first so prose naming the // identifier (there is some, right above it) can't be counted as a read. + // + // Shape as of #3896: the four outbound messages no longer inline + // `convZh ? '' : t(key)` — they go through + // `resolveOutboundAgentText`, which reads the `zh`/`en` packs by conversation + // language. So `convZh` now has exactly ONE consumer, and that consumer is + // the resolver bridge; anything else reading it is a label drifting back in. const code = src.replace(/^\s*\/\/.*$/gm, ''); - const gated = [...code.matchAll(/const (\w+) = convZh\b/g)].map((m) => m[1]); - expect(gated).toEqual([ - 'planApproveMessage', - 'planApproveDefaultsMessage', + const convZhLines = code.split('\n').filter((line) => /\bconvZh\b/.test(line)); + expect( + convZhLines, + 'a convZh read appeared outside the outbound-text resolver — if it feeds anything rendered, it follows the UI locale instead (#3837/#3896)', + ).toHaveLength(3); + expect(convZhLines[0], 'the convZh memo itself').toMatch(/const convZh = useMemo\(/); + expect(convZhLines[1], 'its only consumer is the outbound-text resolver').toMatch( + /resolveOutboundAgentText\(\s*convZh\s*,/, + ); + expect(convZhLines[2], "the bridge's dep array").toMatch(/^\s*\[convZh\],\s*$/); + // And the resolver is asked for outbound MESSAGES only — the same list the + // i18n guard owns (`outbound-agent-messages.test.ts` OUTBOUND_KEYS). A + // `*Label` appearing here would be #3837 all over again, one indirection out. + const resolved = [...code.matchAll(/outboundText\('(\w+)'/g)].map((m) => m[1]).sort(); + expect(resolved).toEqual([ 'changesConfirmMessage', + 'planAnswerMessage', + 'planApproveDefaultsMessage', + 'planApproveMessage', ]); + // #3896's headline defect in one line: an outbound message read through + // `t()` is read from the UI pack, which is how a zh console sent Chinese + // into an English thread. No outbound `*Message` key may go through `t()`. expect( - [...code.matchAll(/\bconvZh\b/g)], - 'a convZh read appeared outside the outbound-message consts — if it feeds anything rendered, it follows the UI locale instead (#3837)', - ).toHaveLength(1 /* the useMemo that defines it */ + gated.length); + code, + 'an outbound *Message is being read from the UI pack again (#3896)', + ).not.toMatch(/t\('console\.ai\.\w*Message'/); // And zh still spells the badge the way the deleted literal did, so the fix // changed WHICH source answers a zh-UI reader, not what they read. expect(at(builtInLocales.zh, 'console.ai.planBuilding')).toBe('正在搭建…'); diff --git a/packages/i18n/src/__tests__/outbound-agent-messages.test.ts b/packages/i18n/src/__tests__/outbound-agent-messages.test.ts index f3a5289c96..97a48a9bbc 100644 --- a/packages/i18n/src/__tests__/outbound-agent-messages.test.ts +++ b/packages/i18n/src/__tests__/outbound-agent-messages.test.ts @@ -10,21 +10,34 @@ * `APPROVAL_RE`) decides whether that text counts as approval. It recognises * Chinese and English. Nothing else. * - * So `AiChatPage` picks them by the language of the CONVERSATION, not the UI: + * So `AiChatPage` picks them by the language of the CONVERSATION, not the UI — + * since objectui#3896 through one resolver + * (`packages/app-shell/src/console/ai/outboundAgentText.ts`) that reads the two + * gate packs directly and never calls `t()` for these four keys: * - * const planApproveMessage = convZh - * ? '确认,开始搭建。' // matches the gate - * : t('console.ai.planApproveMessage', { defaultValue: … }); + * zh conversation -> the `zh` pack's value // matches the gate + * anything else -> the `en` pack's value // matches the gate * - * That `t()` call is expected to MISS in every non-Chinese pack and fall - * through to its English `defaultValue`. Add a German translation and a German - * user's "Build it" click starts sending German, the gate stops matching, and - * the agent re-proposes instead of building — the button looks inert while - * nothing visibly errors. objectui#2900 shipped exactly that for - * `changesConfirmMessage`; this test is why it can't happen twice. + * ## What this test guarantees, stated as what it asserts * - * If the gate ever learns more languages, delete the offending entry here in - * the same change that teaches it — not before. + * Exactly two packs define these keys: `en` and `zh`. That is the whole claim. + * It is NOT "a `t()` lookup MISSes in every non-Chinese pack" — the wording this + * header carried until #3896, which was false for `zh` and was the premise the + * bug hid behind: `zh` defines all four, so the `t()` fallback HIT and a zh + * console sent `确认,开始搭建。` into an English thread. + * + * ## Why the claim still earns its keep after #3896 + * + * A translation added to a ninth pack is now DEAD rather than dangerous — the + * resolver never reads that pack, so the value can never be sent, and an + * unreachable pack value is its own defect class (objectui#3837). Before #3896 + * it was actively harmful: the value WAS sent, the gate stopped matching, the + * agent re-proposed instead of building, and the button merely looked inert + * while nothing visibly errored. objectui#2900 shipped exactly that for + * `changesConfirmMessage`. + * + * If the gate ever learns more languages, teach the resolver in the same change + * that deletes the offending entry here — not before. */ import { describe, it, expect } from 'vitest'; import { en, zh, ja, ko, de, fr, es, pt, ru, ar } from '../locales'; @@ -47,7 +60,7 @@ function lookup(pack: unknown, dotted: string): unknown { describe('outbound agent messages stay on gate-recognised languages', () => { it.each(Object.keys(NON_GATE_LOCALES))( - 'the %s pack defines none of them, so t() falls through to the English default', + 'the %s pack defines none of them — a value there could never be sent', (lang) => { const offenders = OUTBOUND_KEYS.filter( (k) => lookup(NON_GATE_LOCALES[lang], k) !== undefined, @@ -57,6 +70,9 @@ describe('outbound agent messages stay on gate-recognised languages', () => { ); it.each(Object.keys(GATE_LOCALES))('the %s pack defines all of them', (lang) => { + // Both halves are load-bearing since #3896: the resolver reads `zh` for a + // Chinese conversation and `en` for every other one, so a key missing from + // either pack drops that language onto the resolver's fallback table. for (const k of OUTBOUND_KEYS) { expect(typeof lookup(GATE_LOCALES[lang], k)).toBe('string'); }