Skip to content

Commit 57d82cb

Browse files
fix(app-shell,i18n): the AI chat’s tool-approval and Builder handoff labels are translated (objectui#11667) (#11673)
Fixes #11667 Clause-②: yes The console AI chat's inline tool-approval card and its "Open in Builder" handoff card now read every label from the language packs. Before this change, a zh-CN reader saw all six of these strings in English, among labels that were already translated. ## The six labels | Label (English, before) | Where it came from | Now | |---|---|---| | "Approve & run" | literal passed by `ChatPane` | `aiApprovals.approveAndExecute` (reused) | | "Reject" | literal passed by `ChatPane` | `aiApprovals.reject` (reused) | | "Operator rejected from chat" (deny reason) | literal passed by `ChatPane` | `console.ai.toolDenyReason` (new) | | "Build this in the Builder" | `ChatbotEnhanced` default, no host passed it | `console.ai.builderHandoffTitle` (new) | | "Open in Builder →" | `ChatbotEnhanced` default | `console.ai.builderHandoffOpen` (new) | | "A newer request is available" (superseded card tooltip) | `ChatbotEnhanced` default | `console.ai.builderHandoffSuperseded` (new) | **Reused keys first.** The packs already hold the AI Approvals inbox's buttons (`AiPendingActionsInbox`). The chat card makes the same decision on the same pending action, through the same `pending-actions/ID/approve` and `pending-actions/ID/reject` endpoints, so both buttons borrow those keys. This follows the pack's existing borrowing pattern (`detail.justNow` and others). `aiApprovals.reject` is the same word in English. `aiApprovals.approveAndExecute` has the same meaning but different English: **English readers now see "Approve & Execute" in place of "Approve & run"**, which is the inbox's wording for this decision. No pack had a key for the other four strings, so they are new. ## Widened public surface (Clause-②) - `@object-ui/i18n`: four new keys in all ten language packs: `console.ai.toolDenyReason`, `console.ai.builderHandoffTitle`, `console.ai.builderHandoffOpen` and `console.ai.builderHandoffSuperseded`. The exported `en` pack, and the `TranslationKeys` type derived from it, gain those four members. - Nothing else. No component prop, no exported type member, and no change to `@object-ui/plugin-chatbot`. `ChatbotEnhanced` already took all six strings as props (`toolApproveLabel`, `toolDenyLabel`, `toolDenyReason`, `builderHandoffTitleLabel`, `builderHandoffOpenLabel`, `builderHandoffSupersededTitle`). The changeset `.changeset/11667-ai-chat-labels-i18n.md` is `minor` for both packages and lists the same four keys. ## The deny reason: translated, because nothing machine-reads it The card says the reason stays fixed English if a machine reads it. I traced every reader: 1. **Where it is recorded.** `useHitlInChat` sends it as `{ reason }` on `POST pending-actions/ID/reject`. The server stores it as the pending action's `rejection_reason`. The spec describes that field (`AiPendingActionSchema`) as "Reason given at rejection". The `IAIService.rejectPendingAction` contract says the reason "is stored so the next LLM turn can surface it". 2. **People read it.** The AI Approvals inbox drawer renders `rejection_reason`. That inbox's own reject dialog writes the reviewer's free text, in any language, into the same field through the same endpoint. So the field already holds text in any language. 3. **The model reads it as prose.** `useHitlInChat` puts it in the continuation prompt it sends into the thread after a reject (`… Reason: TEXT.`). 4. **No code parses it.** The last `@objectstack/service-ai` published from the framework repo (10.3.0, read from its npm tarball) checks only `typeof body.reason === 'string'` in the reject route. It stores the value verbatim and describes the field as "Why the reviewer rejected (shown back to the LLM)". The literal "Operator rejected from chat" has zero hits in the objectstack tree. **NOT MEASURED:** the current `service-ai` lives in the cloud repository, which this session cannot reach. One machine may see the reason: the cloud confirm gate (`service-ai-studio` `APPROVAL_RE`), if it scans the continuation prompt. That gate must never read a rejection as approval. A new pin in `packages/i18n/src/__tests__/i18n.test.ts` checks every pack's `console.ai.toolDenyReason` against the verbatim gate mirror already in that file. All ten values stay clear of it. So the reason follows the UI locale through `t()`, like the inbox's own free-text reason. ## The repo's rule for the packs `all-locales-key-parity.test.ts` requires every pack to define every `en` key. `untranslated-identity-4376.test.ts` treats a non-Latin value that is byte-identical to `en` as an untranslated omission. So each of the ten packs carries a real translation, and no pack copies the English. "Builder" follows each pack's existing word for it (`useVisualBuilder`, `navBuilder`), and the arrow follows the existing "→" keys (`designInStudio`). ## Tests Run on the final head `fc3338c` unless a line says otherwise, from the repo root, under the shared verify lock. Each exit code was written to a file as it ran. - **New pin**, `packages/app-shell/src/console/ai/__tests__/AiChatPage.toolLabelsLocale-11667.test.tsx`. It renders `ChatPane` with the **real** `ChatbotEnhanced` under `I18nProvider` in `zh-CN`. The conversation is a fixture: one tool call awaiting approval, plus two `suggest_builder` handoffs, so the older card is the superseded one. It asserts four things: - each surface renders its Chinese value; - none of the six English strings appears in the page text or in any `title` / `aria-label` / `placeholder`; - the reject request's body is `{ reason: '操作员已在对话中拒绝' }`; - the continuation prompt carries that same reason. An `en` control renders the same fixture through the same probe and finds the English strings. This shows the probe can see them, so the zh-CN absence is a real reading and not an empty render. - **New pin**, `packages/i18n/src/__tests__/i18n.test.ts`: every pack's `console.ai.toolDenyReason` resolves and does not match the mirrored `APPROVAL_RE`. - `pnpm exec vitest run packages/app-shell/src/console/ai/`: `Test Files 32 passed (32)`, `Tests 184 passed (184)`, exit 0. - `pnpm exec vitest run packages/i18n/`: `Test Files 79 passed (79)`, `Tests 1290 passed | 13 skipped (1303)`, exit 0. - `pnpm --filter @object-ui/app-shell type-check`: exit 0. `lint`: `0 errors`. The warnings in `AiChatPage.tsx` are pre-existing `react-refresh` ones, and the new test file has none. - The dependency closure, through `turbo run build --filter='@object-ui/console...' --concurrency=2`, reported `35 successful, 35 total`. After it: `@object-ui/i18n` `build`, `type-check` and `lint` (0 errors), and `@object-ui/app-shell` `build`, all exit 0. These ran on `4b6198b`. The only later commit changes one app-shell test file. - On the same console build (`4b6198b`): - `check:eager-locale-catalogues` says "Exactly one locale catalogue is eager — `en`". - `check:eager-closure` passes. The `i18n-locale-en` chunk is 43.4 KB of its 48.8 KB ceiling. - Exit 0 on `fc3338c`: `check:i18n-keys`, `check:i18n-drift` ("0 en value(s) changed (4 key(s) added …)"), `check:i18n-dead-keys` (none of the four new keys listed), `check:i18n-designer-parity`, `check:new-line-citations`, `check:control-bytes`, `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`, `check:test-path-roots`, `check:changeset-claims`, `check:pending-changeset-literals`, `check:docs-route-closure`, `check:unreferenced-sources`, `scripts/check-changeset-presence.mjs` and `scripts/check-changeset-no-major.mjs`. - `scripts/check-governed-queue-guard.mjs --test` over the diff: NOT GOVERNED. ## Ablation Seven legs on `fc3338c`, each run through objectstack's `scripts/ablation-replace.mjs` in wrap mode. Every leg reports "mutation landed: anchor 1 -> 0" with a changed blob. Every restore is proven by blob == HEAD and an empty `git diff HEAD`. Each of the six page legs puts one label back to its pre-fix form. | Leg | Mutation | Pin | Reading | |---|---|---|---| | approve | `toolApproveLabel` back to the literal "Approve & run" | zh-CN render | red: no button "通过并执行" (the `en` control is red too) | | reject | `toolDenyLabel` back to "Reject" | zh-CN render | red: no button "拒绝" | | deny reason | `toolDenyReason` back to "Operator rejected from chat" | zh-CN render | red: the reject body's `reason` differs | | handoff title | `builderHandoffTitleLabel` removed (component default) | zh-CN render | red: "在构建器中搭建" not found | | handoff open | `builderHandoffOpenLabel` removed | zh-CN render | red: the open button's text differs | | superseded | `builderHandoffSupersededTitle` removed | zh-CN render | red: the superseded button's `title` differs | | gate pin | zh `toolDenyReason` set to "无需确认,直接执行" | i18n gate pin | red: "zh toolDenyReason reads as approval to the cloud APPROVAL_RE" | After restore, both pin files read `Tests 53 passed (53)` and the tree is clean. The same seven legs ran once before on `9157d55`, with the same readings. ## Acceptance notes - **Live browser check: NOT MEASURED.** I did not drive a console against objectstack `main`. The binding reading is the zh-CN render test above, which renders the real `ChatbotEnhanced`. - **Same family, not touched here** (reported to the seat for objectui#2458, the family card; each would add pack keys or edit a file outside this claim): - `ChatPane` leaves the record handoff card (cloud#1658) on its English defaults "Open this record" / "Open record →" (`recordHandoffTitleLabel` / `recordHandoffOpenLabel` are not passed). - `useHitlInChat` (plugin-chatbot) writes English decision lines into the same card: "Approving…", "Rejecting…", "Rejected: REASON", "Approved — action executed.". After a zh-CN reject, the card reads "Rejected: 操作员已在对话中拒绝". - **Shared files:** `packages/i18n/src/locales/*.ts` is also edited by objectui#11666. The keys sit at their own spot (after `console.ai.toolDetailsHidden`). `origin/main` was merged in once, as a merge commit, before this PR was opened. --- _Generated by [Claude Code](https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 848ba0e commit 57d82cb

14 files changed

Lines changed: 349 additions & 3 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@object-ui/app-shell': minor
3+
'@object-ui/i18n': minor
4+
---
5+
6+
The AI chat's tool-approval card and its "Open in Builder" handoff card are translated in every language (objectui#11667).
7+
8+
The console's AI chat passed three English literals to the inline approval card: "Approve & run", "Reject", and the deny reason "Operator rejected from chat". It also left the handoff card on its English defaults: "Build this in the Builder", "Open in Builder →", and the superseded card's tooltip "A newer request is available". A zh-CN reader met all six in English, among labels that were already translated.
9+
10+
All six now come from the language packs:
11+
12+
- The approve and reject buttons reuse the AI Approvals inbox's keys `aiApprovals.approveAndExecute` and `aiApprovals.reject`. They make the same decision on the same pending action, through the same endpoint. English readers now see "Approve & Execute" in place of "Approve & run", the inbox's wording.
13+
- The deny reason, sent as `reason` on the reject request and stored as the pending action's `rejection_reason`, follows the UI language. People read it in the AI Approvals inbox, and the model reads it as prose on its next turn. No code parses it.
14+
- The handoff card's three strings are new keys.
15+
16+
**Widened public surface (`@object-ui/i18n`).** Four new keys in all ten language packs, so the exported `en` pack and the `TranslationKeys` type derived from it gain four members:
17+
18+
- `console.ai.toolDenyReason`
19+
- `console.ai.builderHandoffTitle`
20+
- `console.ai.builderHandoffOpen`
21+
- `console.ai.builderHandoffSuperseded`
22+
23+
No component prop or exported type changes. `ChatbotEnhanced` already took all six strings as props.

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

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2393,6 +2393,11 @@ export function ChatPane({
23932393
: undefined
23942394
}
23952395
onOpenBuilder={openBuilder}
2396+
// objectui#11667 — the ADR-0057 P4 handoff card's three strings. Left
2397+
// unset, the component's English defaults rendered in every locale.
2398+
builderHandoffTitleLabel={t('console.ai.builderHandoffTitle')}
2399+
builderHandoffOpenLabel={t('console.ai.builderHandoffOpen')}
2400+
builderHandoffSupersededTitle={t('console.ai.builderHandoffSuperseded')}
23962401
onOpenRecord={openRecord}
23972402
surface="plain"
23982403
maxHeight="100%"
@@ -2480,9 +2485,16 @@ export function ChatPane({
24802485
enableMarkdown
24812486
onToolApprove={hitl.decide}
24822487
toolDecisions={hitl.decisions}
2483-
toolApproveLabel="Approve & run"
2484-
toolDenyLabel="Reject"
2485-
toolDenyReason="Operator rejected from chat"
2488+
// objectui#11667 — the inline HITL decision card. The two buttons borrow
2489+
// the AI Approvals inbox's keys: the same decision on the same pending
2490+
// action through the same endpoint, so the two surfaces say it alike.
2491+
toolApproveLabel={t('aiApprovals.approveAndExecute')}
2492+
toolDenyLabel={t('aiApprovals.reject')}
2493+
// The deny reason is stored as the pending action's `rejection_reason`
2494+
// and read as prose: by people in the AI Approvals inbox (which writes
2495+
// free text in any language into the same field) and by the model on
2496+
// its next turn. No code parses it, so it follows the UI locale.
2497+
toolDenyReason={t('console.ai.toolDenyReason')}
24862498
// Build-tree "Open app": jump straight into the app the agent just built.
24872499
onOpenBuiltApp={(appName, appSegment) =>
24882500
navigate(`/apps/${encodeURIComponent(appSegment ?? appName)}`)}
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectui#11667 — the AI chat's tool-approval card and Builder handoff card
5+
* speak the session's language.
6+
*
7+
* `ChatPane` handed `ChatbotEnhanced` three literals for the inline HITL card
8+
* ("Approve & run", "Reject", and the deny reason "Operator rejected from
9+
* chat") and left the ADR-0057 P4 handoff card on the component's English
10+
* defaults ("Build this in the Builder", "Open in Builder →", and the
11+
* superseded card's tooltip "A newer request is available"). Every other label
12+
* on the same screen was already a `console.ai.*` key, so a zh-CN reader met
13+
* these six in English.
14+
*
15+
* Rendered as shipped: the REAL `ChatbotEnhanced` under a real `I18nProvider`,
16+
* with only the transport (`useObjectChat`) stubbed so the conversation is a
17+
* fixture — one tool call awaiting approval, and two handoff cards, so one of
18+
* them is the superseded card whose tooltip is the sixth string. The deny
19+
* reason is read where it is RECORDED: the body of the reject request the real
20+
* `useHitlInChat` sends.
21+
*/
22+
import '@testing-library/jest-dom/vitest';
23+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
24+
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
25+
import { MemoryRouter } from 'react-router-dom';
26+
import React from 'react';
27+
28+
/** The six strings the card names, as they used to reach every locale. */
29+
const ENGLISH = [
30+
'Approve & run',
31+
'Reject',
32+
'Operator rejected from chat',
33+
'Build this in the Builder',
34+
'Open in Builder →',
35+
'A newer request is available',
36+
] as const;
37+
38+
/**
39+
* The conversation: one destructive tool call held for approval (the shape
40+
* `mapMessages` lifts from a `pending_approval` result), then two
41+
* `suggest_builder` handoffs — the older one is superseded by the newer one.
42+
* Module-scoped so the stubbed hook hands back one stable array.
43+
*/
44+
const MESSAGES = [
45+
{ id: 'u1', role: 'user', content: 'delete the task' },
46+
{
47+
id: 'a1',
48+
role: 'assistant',
49+
content: '',
50+
toolInvocations: [
51+
{
52+
toolCallId: 'call-approve',
53+
toolName: 'action_delete_task',
54+
args: { id: 't1' },
55+
state: 'approval-requested',
56+
pendingActionId: 'pa_11667',
57+
},
58+
],
59+
},
60+
{
61+
id: 'a2',
62+
role: 'assistant',
63+
content: '',
64+
toolInvocations: [
65+
{
66+
toolCallId: 'call-handoff-old',
67+
toolName: 'suggest_builder',
68+
state: 'output-available',
69+
result: { handoff: 'build', prompt: 'Build a CRM' },
70+
builderHandoff: { prompt: 'Build a CRM' },
71+
},
72+
],
73+
},
74+
{
75+
id: 'a3',
76+
role: 'assistant',
77+
content: '',
78+
toolInvocations: [
79+
{
80+
toolCallId: 'call-handoff-new',
81+
toolName: 'suggest_builder',
82+
state: 'output-available',
83+
result: { handoff: 'build', prompt: 'Build a CRM with deals' },
84+
builderHandoff: { prompt: 'Build a CRM with deals' },
85+
},
86+
],
87+
},
88+
];
89+
90+
const sendMessage = vi.fn();
91+
92+
vi.mock('@object-ui/plugin-chatbot', async (importOriginal) => {
93+
const actual = await importOriginal<Record<string, unknown>>();
94+
return {
95+
...actual,
96+
// `ChatbotEnhanced` stays REAL — the rendered card is the surface under test.
97+
useObjectChat: () => ({
98+
messages: MESSAGES,
99+
isLoading: false,
100+
error: undefined,
101+
sendMessage,
102+
stop: vi.fn(),
103+
reload: vi.fn(),
104+
clear: vi.fn(),
105+
setMessages: vi.fn(),
106+
}),
107+
useAiModels: () => ({ models: [], defaultModelId: undefined }),
108+
};
109+
});
110+
111+
vi.mock('../../../providers/MetadataProvider', async (importOriginal) => {
112+
const actual = await importOriginal<Record<string, unknown>>();
113+
return { ...actual, useMetadata: () => ({ apps: [] }) };
114+
});
115+
vi.mock('../../../providers/AdapterProvider', async (importOriginal) => {
116+
const actual = await importOriginal<Record<string, unknown>>();
117+
return { ...actual, useAdapter: () => null };
118+
});
119+
120+
import { I18nProvider } from '@object-ui/i18n';
121+
import type { AgentDescriptor } from '@object-ui/plugin-chatbot';
122+
import { ChatPane } from '../AiChatPage';
123+
124+
window.matchMedia = ((query: string) => ({
125+
matches: false,
126+
media: query,
127+
onchange: null,
128+
addEventListener: () => {},
129+
removeEventListener: () => {},
130+
addListener: () => {},
131+
removeListener: () => {},
132+
dispatchEvent: () => false,
133+
})) as unknown as typeof window.matchMedia;
134+
135+
function renderPane(language: string) {
136+
return render(
137+
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }} persistLanguage={false}>
138+
<MemoryRouter initialEntries={['/ai/ask/conv-1']}>
139+
<ChatPane
140+
agents={[{ name: 'ask', label: 'Ask' } as unknown as AgentDescriptor]}
141+
agentsLoading={false}
142+
agentsError={undefined}
143+
activeAgent="ask"
144+
chatApi="/api/v1/ai/agents/ask/chat"
145+
apiBase="/api/v1/ai"
146+
conversationId="conv-1"
147+
initialMessages={[]}
148+
pendingFirstMessageRef={{ current: null }}
149+
onSent={vi.fn()}
150+
onShare={vi.fn()}
151+
/>
152+
</MemoryRouter>
153+
</I18nProvider>,
154+
);
155+
}
156+
157+
/**
158+
* Everything a reader can meet on the page: the text, and the attributes that
159+
* surface as tooltips or accessible names. The superseded card's string lives
160+
* ONLY in a `title`, so text alone would miss it.
161+
*/
162+
function readable(root: HTMLElement): string[] {
163+
const out = [root.textContent ?? ''];
164+
for (const el of Array.from(root.querySelectorAll('*'))) {
165+
for (const attr of ['title', 'aria-label', 'placeholder']) {
166+
const v = el.getAttribute(attr);
167+
if (v) out.push(v);
168+
}
169+
}
170+
return out;
171+
}
172+
173+
function englishFound(root: HTMLElement): string[] {
174+
const texts = readable(root);
175+
return ENGLISH.filter((s) => texts.some((t) => t.includes(s)));
176+
}
177+
178+
/** The reject request `useHitlInChat` sends — where the deny reason is recorded. */
179+
function rejectRequests(fetchMock: ReturnType<typeof vi.fn>) {
180+
return fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/pending-actions/pa_11667/reject'));
181+
}
182+
183+
/** The slice of `Response` the page's requests read here. */
184+
interface FakeResponse {
185+
ok: boolean;
186+
status: number;
187+
text: () => Promise<string>;
188+
json: () => Promise<unknown>;
189+
}
190+
191+
let fetchMock: ReturnType<typeof vi.fn>;
192+
193+
beforeEach(() => {
194+
window.localStorage.clear();
195+
sendMessage.mockReset();
196+
fetchMock = vi.fn(async (url: unknown): Promise<FakeResponse> => {
197+
if (String(url).endsWith('/pending-actions/pa_11667/reject')) {
198+
const body = JSON.stringify({ status: 'rejected', id: 'pa_11667' });
199+
return { ok: true, status: 200, text: async () => body, json: async () => JSON.parse(body) };
200+
}
201+
return { ok: false, status: 404, text: async () => '', json: async () => ({}) };
202+
});
203+
vi.stubGlobal('fetch', fetchMock);
204+
});
205+
afterEach(() => {
206+
cleanup();
207+
vi.unstubAllGlobals();
208+
});
209+
210+
describe('AI chat tool-approval and Builder handoff labels follow the UI locale (objectui#11667)', () => {
211+
it('zh-CN: none of the six English strings reaches the reader, and each surface renders in Chinese', async () => {
212+
const { container } = renderPane('zh-CN');
213+
214+
// Each surface is on the page — so the absence below is a reading, not an
215+
// empty render.
216+
expect(await screen.findByRole('button', { name: '通过并执行' })).toBeInTheDocument();
217+
expect(screen.getByRole('button', { name: '拒绝' })).toBeInTheDocument();
218+
expect(screen.getAllByText('在构建器中搭建')).toHaveLength(2);
219+
expect(screen.getByTestId('builder-handoff-open')).toHaveTextContent('在构建器中打开 →');
220+
expect(screen.getByTestId('builder-handoff-superseded')).toHaveAttribute('title', '已有更新的请求');
221+
222+
expect(englishFound(container)).toEqual([]);
223+
224+
// The deny reason, where it is recorded: the reject request's body.
225+
fireEvent.click(screen.getByRole('button', { name: '拒绝' }));
226+
await waitFor(() => expect(rejectRequests(fetchMock)).toHaveLength(1));
227+
const [, init] = rejectRequests(fetchMock)[0];
228+
expect(JSON.parse(String((init as RequestInit).body))).toEqual({ reason: '操作员已在对话中拒绝' });
229+
// …and the model's next turn reads the same translated reason.
230+
await waitFor(() => expect(sendMessage).toHaveBeenCalledTimes(1));
231+
expect(String(sendMessage.mock.calls[0][0])).toContain('操作员已在对话中拒绝');
232+
expect(String(sendMessage.mock.calls[0][0])).not.toContain('Operator rejected from chat');
233+
});
234+
235+
it('en: the same fixture shows the English labels — the probe above can see them', async () => {
236+
// Control for the zh-CN reading: same fixture, same probe, English UI.
237+
// "Approve & run" is the one string English readers no longer see — the
238+
// card now borrows the AI Approvals inbox's wording for the same decision.
239+
const { container } = renderPane('en');
240+
expect(await screen.findByRole('button', { name: 'Approve & Execute' })).toBeInTheDocument();
241+
expect(englishFound(container)).toEqual(ENGLISH.filter((s) => s !== 'Approve & run' && s !== 'Operator rejected from chat'));
242+
243+
await act(async () => {
244+
fireEvent.click(screen.getByRole('button', { name: 'Reject' }));
245+
});
246+
await waitFor(() => expect(rejectRequests(fetchMock)).toHaveLength(1));
247+
const [, init] = rejectRequests(fetchMock)[0];
248+
expect(JSON.parse(String((init as RequestInit).body))).toEqual({ reason: 'Operator rejected from chat' });
249+
});
250+
});

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,23 @@ describe('@object-ui/i18n', () => {
170170
expect(msg).not.toMatch(GATE);
171171
});
172172

173+
it('the inline HITL deny reason never reads as approval, in any pack (objectui#11667)', () => {
174+
// `console.ai.toolDenyReason` is a label — it follows the UI locale, so
175+
// all ten packs define it — but it does not stay on screen: the reject
176+
// request stores it, and `useHitlInChat` embeds it in the continuation
177+
// prompt it sends into the thread after the reject. A translation the
178+
// gate read as approval would turn the operator's rejection into a
179+
// go-ahead in that thread.
180+
for (const lang of ['en', 'zh', 'ja', 'ko', 'de', 'fr', 'es', 'pt', 'ru', 'ar']) {
181+
const i18n = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false });
182+
const msg = i18n.t('console.ai.toolDenyReason');
183+
expect(msg, `${lang} toolDenyReason must not fall through to its raw key`).not.toBe(
184+
'console.ai.toolDenyReason',
185+
);
186+
expect(msg, `${lang} toolDenyReason reads as approval to the cloud APPROVAL_RE`).not.toMatch(GATE);
187+
}
188+
});
189+
173190
it('translates common keys in Japanese', () => {
174191
const i18n = createI18n({ defaultLanguage: 'ja', detectBrowserLanguage: false });
175192
expect(i18n.t('common.save')).toBe('保存');

‎packages/i18n/src/locales/ar.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1948,6 +1948,10 @@ const ar = {
19481948
toolAwaitingApproval: "بانتظار الموافقة",
19491949
toolFailed: "فشل",
19501950
toolDetailsHidden: "مدخلات الأدوات والنتائج الخام مخفية في هذا العرض.",
1951+
toolDenyReason: "رفضه المشغّل من المحادثة",
1952+
builderHandoffTitle: "الإنشاء في المُنشئ",
1953+
builderHandoffOpen: "فتح في المُنشئ →",
1954+
builderHandoffSuperseded: "يتوفر طلب أحدث",
19511955
copy: "نسخ",
19521956
copied: "تم النسخ",
19531957
regenerate: "إعادة التوليد",

‎packages/i18n/src/locales/de.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1763,6 +1763,10 @@ const de = {
17631763
toolAwaitingApproval: "Warten auf Freigabe",
17641764
toolFailed: "Fehlgeschlagen",
17651765
toolDetailsHidden: "Tool-Eingaben und Rohergebnisse sind in dieser Ansicht ausgeblendet.",
1766+
toolDenyReason: "Im Chat vom Bediener abgelehnt",
1767+
builderHandoffTitle: "Im Builder erstellen",
1768+
builderHandoffOpen: "Im Builder öffnen →",
1769+
builderHandoffSuperseded: "Eine neuere Anfrage liegt vor",
17661770
copy: "Kopieren",
17671771
copied: "Kopiert",
17681772
regenerate: "Neu generieren",

‎packages/i18n/src/locales/en.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2271,6 +2271,14 @@ const en = {
22712271
toolAwaitingApproval: 'Awaiting approval',
22722272
toolFailed: 'Failed',
22732273
toolDetailsHidden: 'Tool inputs and raw results are hidden in this view.',
2274+
// objectui#11667 — the inline HITL deny reason (stored on the pending
2275+
// action as `rejection_reason`, read as prose) and the ADR-0057 P4
2276+
// "Open in Builder" handoff card. The card's Approve / Reject buttons
2277+
// borrow `aiApprovals.approveAndExecute` / `aiApprovals.reject`.
2278+
toolDenyReason: 'Operator rejected from chat',
2279+
builderHandoffTitle: 'Build this in the Builder',
2280+
builderHandoffOpen: 'Open in Builder →',
2281+
builderHandoffSuperseded: 'A newer request is available',
22742282
copy: 'Copy',
22752283
copied: 'Copied',
22762284
regenerate: 'Regenerate',

‎packages/i18n/src/locales/es.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1809,6 +1809,10 @@ const es = {
18091809
toolAwaitingApproval: "Esperando aprobación",
18101810
toolFailed: "Error",
18111811
toolDetailsHidden: "Las entradas de las herramientas y los resultados sin procesar están ocultos en esta vista.",
1812+
toolDenyReason: "Rechazado por el operador desde el chat",
1813+
builderHandoffTitle: "Crear en el constructor",
1814+
builderHandoffOpen: "Abrir en el constructor →",
1815+
builderHandoffSuperseded: "Hay una solicitud más reciente",
18121816
copy: "Copiar",
18131817
copied: "Copiado",
18141818
regenerate: "Regenerar",

‎packages/i18n/src/locales/fr.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1806,6 +1806,10 @@ const fr = {
18061806
toolAwaitingApproval: "En attente d'approbation",
18071807
toolFailed: "Échec",
18081808
toolDetailsHidden: "Les entrées des outils et les résultats bruts sont masqués dans cette vue.",
1809+
toolDenyReason: "Rejeté par l'opérateur depuis le chat",
1810+
builderHandoffTitle: "Créer dans le constructeur",
1811+
builderHandoffOpen: "Ouvrir dans le constructeur →",
1812+
builderHandoffSuperseded: "Une demande plus récente est disponible",
18091813
copy: "Copier",
18101814
copied: "Copié",
18111815
regenerate: "Régénérer",

0 commit comments

Comments
 (0)