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
46 changes: 46 additions & 0 deletions .changeset/outbound-agent-conversation-language-3896.md
Original file line number Diff line number Diff line change
@@ -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 ? '<Chinese literal>' : 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.
38 changes: 18 additions & 20 deletions packages/app-shell/src/console/ai/AiChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -1582,35 +1583,36 @@ 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
// readers while making the zh pack's `console.ai.planBuilding` unreachable for
// 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 ? '<Chinese>' : 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<Record<string, string>>) =>
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(
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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<string, unknown>) {
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<string, unknown>, 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,
);
});
});
Original file line number Diff line number Diff line change
@@ -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<string, unknown> } }).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'));
});
});
});
Loading
Loading