Skip to content

Commit 3c4d935

Browse files
os-zhuangclaude
andauthored
fix(i18n): compose the AI-model diagnostics summary client-side (#2886) (#2912)
CloudAiModelStatus rendered `report.summary` verbatim — the most prominent line on the panel, in English for every locale. Reading objectstack-ai/cloud settled how to fix it. The server CANNOT localize that string as currently built: service-ai/src/effective-model.ts:117 assembles it as a hard-coded English template literal with no locale parameter, and routes/ai-routes.ts:395 declares `handler: async () => …` — taking no request argument, so it cannot read Accept-Language even though createAuthenticatedFetch has been sending it since #1319. But no server change is needed: every ingredient of the sentence is already in the structured payload (conversational.model/source, structured.model/pinned, routing.free/paid). The issue proposed "return structured data instead of a sentence" as the better fix — the server was already doing that, the client just wasn't using it. The panel now composes the line itself. sourceLabel() already produced exactly the clauses the server hand-rolls ("pinned by X", "code default (no env override)", "same as build/ask"), so no new source vocabulary was needed. A dropped diagnostic, not just untranslated text: the client's EffectiveModelReport never declared `routing`, which the server has always sent conditionally. Its only appearance anywhere was inside the English summary, so non-English admins could not see the plan→model routing policy at all. Now declared and surfaced. Also fixed: attributeSource emits the bare token 'unknown' when the adapter cannot report a model, and sourceLabel fell through to rendering it raw. Four keys added to all ten packs, so the full-parity guard from #2909 stays green. The panel had no test coverage at all; it now has five, mutation-tested by restoring `<p>{report.summary}</p>` — which fails four of them. Claude-Session: https://claude.ai/code/session_01TubWYdWquVkS9dj733sDmC Co-authored-by: Claude <noreply@anthropic.com>
1 parent eea4391 commit 3c4d935

13 files changed

Lines changed: 255 additions & 1 deletion

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
"@object-ui/i18n": patch
3+
"@object-ui/app-shell": patch
4+
---
5+
6+
fix(i18n): compose the AI-model diagnostics summary client-side instead of rendering the server's English string (objectui#2886)
7+
8+
`CloudAiModelStatus` rendered `report.summary` verbatim — the most prominent
9+
line on the panel, in English for every locale.
10+
11+
Reading `objectstack-ai/cloud` settled how to fix it. The server **cannot**
12+
localize that string as currently built:
13+
14+
- `service-ai/src/effective-model.ts:117` assembles it as a hard-coded English
15+
template literal, with no locale parameter;
16+
- `service-ai/src/routes/ai-routes.ts:395` declares `handler: async () => …`
17+
it takes **no request argument**, so it cannot read `Accept-Language` even
18+
though `createAuthenticatedFetch` has been sending it since objectui#1319.
19+
20+
But no server change is needed, because every ingredient of the sentence is
21+
already in the structured payload: `conversational.model`,
22+
`conversational.source`, `structured.model`, `structured.pinned`, and
23+
`routing.{free,paid}`. The issue proposed "return structured data instead of a
24+
sentence" as the better fix — the server was already doing that; the client
25+
just wasn't using it.
26+
27+
The panel now composes the line from those fields. `sourceLabel()` already
28+
produced exactly the two clauses the server hand-rolls — "pinned by X" /
29+
"code default (no env override)", and "same as build/ask" for an unpinned
30+
structured model — so no new source vocabulary was required.
31+
32+
**A dropped diagnostic, not just untranslated text.** The client's
33+
`EffectiveModelReport` never declared `routing`, which the server has always
34+
sent conditionally. Its only appearance anywhere was inside the English summary,
35+
so non-English admins could not see the plan→model routing policy **at all**.
36+
It is now declared and surfaced.
37+
38+
Also fixed: `attributeSource` emits the bare token `'unknown'` when the adapter
39+
cannot report a model, and `sourceLabel` fell through to rendering it raw.
40+
41+
Four keys added to all ten packs (`summary`, `summaryRouting`, `modelUnknown`,
42+
`sourceUnknown`), so the full-parity guard from objectui#2909 stays green.
43+
44+
The panel had **no test coverage at all**; it now has five, mutation-tested by
45+
restoring `<p>{report.summary}</p>` — which fails four of them.
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The AI-model diagnostics panel composes its own summary line (objectui#2886).
5+
*
6+
* `service-ai` builds `report.summary` as a hard-coded English template literal
7+
* and its route handler takes no request argument, so it cannot honour
8+
* `Accept-Language` even though the client has been sending it since
9+
* objectui#1319. Every ingredient of that sentence is already in the structured
10+
* fields, so the panel assembles a localized equivalent and ignores `summary`.
11+
*
12+
* These tests pin the two things that regressed before: the English string must
13+
* not reach the DOM, and `routing` — which the server always sent but the client
14+
* never declared — must be surfaced.
15+
*/
16+
import '@testing-library/jest-dom/vitest';
17+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
18+
import { render, screen, waitFor, cleanup } from '@testing-library/react';
19+
import { I18nProvider, createI18n } from '@object-ui/i18n';
20+
21+
vi.mock('@object-ui/auth', () => ({
22+
createAuthenticatedFetch: () => vi.fn().mockImplementation(() => Promise.resolve(mockResponse())),
23+
}));
24+
25+
let payload: Record<string, unknown>;
26+
const mockResponse = () =>
27+
({ ok: true, status: 200, json: () => Promise.resolve(payload) }) as unknown as Response;
28+
29+
import { CloudAiModelStatus } from './CloudAiModelStatus';
30+
31+
/** The English sentence the server sends and this panel must NOT render. */
32+
const SERVER_SUMMARY =
33+
'build/ask uses gpt-5 (pinned by OS_AI_MODEL); structured uses gpt-5-mini. ' +
34+
'Routing policy: free plans → gpt-5-mini, paid plans → gpt-5.';
35+
36+
function baseReport(extra: Record<string, unknown> = {}) {
37+
return {
38+
conversational: { model: 'gpt-5', source: 'env:OS_AI_MODEL' },
39+
structured: { model: 'gpt-5-mini', pinned: true, source: 'env:OS_AI_MODEL_STRUCTURED' },
40+
reasoningEffort: { effective: 'medium', source: 'code-default' },
41+
adapter: 'vercel',
42+
overrides: { OS_AI_MODEL: 'gpt-5' },
43+
summary: SERVER_SUMMARY,
44+
...extra,
45+
};
46+
}
47+
48+
function renderPanel(lang = 'en') {
49+
// `I18nProvider` takes a config/instance, not a `language` prop — passing one
50+
// is silently ignored at runtime, so pin the language on the instance.
51+
const instance = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false });
52+
return render(
53+
<I18nProvider instance={instance}>
54+
<CloudAiModelStatus />
55+
</I18nProvider>,
56+
);
57+
}
58+
59+
beforeEach(() => {
60+
payload = baseReport();
61+
});
62+
afterEach(cleanup);
63+
64+
describe('CloudAiModelStatus — summary is composed client-side', () => {
65+
it('renders a localized summary and never the server’s English string', async () => {
66+
renderPanel('en');
67+
const line = await screen.findByTestId('ai-model-summary');
68+
69+
expect(line).toHaveTextContent('gpt-5');
70+
expect(line).toHaveTextContent('gpt-5-mini');
71+
// The regression: `<p>{report.summary}</p>` put this verbatim on screen for
72+
// every locale.
73+
expect(line.textContent).not.toBe(SERVER_SUMMARY);
74+
expect(document.body.textContent).not.toContain('build/ask uses');
75+
});
76+
77+
it('surfaces the routing policy the client type used to drop', async () => {
78+
payload = baseReport({ routing: { free: 'gpt-5-mini', paid: 'gpt-5' } });
79+
renderPanel('en');
80+
const line = await screen.findByTestId('ai-model-summary');
81+
// Routing only ever appeared inside the English summary, so non-English
82+
// admins could not see it at all.
83+
expect(line).toHaveTextContent(/Routing policy/i);
84+
expect(line).toHaveTextContent('gpt-5-mini');
85+
});
86+
87+
it('omits the routing clause when the host does not inject one', async () => {
88+
renderPanel('en');
89+
const line = await screen.findByTestId('ai-model-summary');
90+
expect(line).not.toHaveTextContent(/Routing policy/i);
91+
});
92+
93+
it('translates the summary — a zh render shares no sentence text with en', async () => {
94+
payload = baseReport({ routing: { free: 'gpt-5-mini', paid: 'gpt-5' } });
95+
const { unmount } = renderPanel('en');
96+
const en = (await screen.findByTestId('ai-model-summary')).textContent ?? '';
97+
unmount();
98+
99+
renderPanel('zh');
100+
const zh = (await screen.findByTestId('ai-model-summary')).textContent ?? '';
101+
102+
expect(zh).not.toBe(en);
103+
expect(zh).toMatch(/[-鿿]/);
104+
// Model ids are identifiers, not prose — they must survive translation.
105+
expect(zh).toContain('gpt-5');
106+
});
107+
108+
it('falls back to a translated placeholder when the adapter reports no model', async () => {
109+
payload = baseReport({
110+
conversational: { model: undefined, source: 'unknown' },
111+
structured: { model: undefined, pinned: false, source: 'inherits-conversational' },
112+
});
113+
renderPanel('en');
114+
const line = await screen.findByTestId('ai-model-summary');
115+
// `attributeSource` emits the bare token 'unknown'; it used to render raw.
116+
expect(line.textContent).not.toMatch(/\bunknown\s*\)/);
117+
expect(line).toHaveTextContent(/not reported by the adapter/i);
118+
});
119+
});

packages/app-shell/src/console/diagnostics/CloudAiModelStatus.tsx

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,28 @@ interface EffectiveModelReport {
3131
adapter: string;
3232
provider?: string;
3333
overrides: Record<string, string | null>;
34+
/**
35+
* Plan→model routing for this deploy, injected by the host (objectos-runtime
36+
* owns `planToAiModel`; service-ai stays plan-agnostic), so the report says
37+
* what EACH tier gets rather than only the model the live adapter happens to
38+
* hold. Omitted by hosts with no plan concept.
39+
*
40+
* The server has always sent this; the client just never declared it, so the
41+
* only place it surfaced was inside the English `summary` string — meaning
42+
* non-English admins could not see the routing policy at all (objectui#2886).
43+
*/
44+
routing?: { free: string; paid: string };
45+
/**
46+
* Server-composed one-line summary. **Deliberately not rendered.**
47+
*
48+
* It is assembled in `service-ai`'s `buildEffectiveModelReport` as a
49+
* hard-coded English template literal, and the route handler there takes no
50+
* request argument at all — so it cannot read `Accept-Language` even though
51+
* `createAuthenticatedFetch` has been sending it since objectui#1319. Every
52+
* ingredient of the sentence is already in the structured fields above, so
53+
* this panel composes its own localized version instead. Kept in the type
54+
* because it is part of the wire contract, not because it is used.
55+
*/
3456
summary: string;
3557
}
3658

@@ -53,6 +75,10 @@ function sourceLabel(source: string, t: TFn): string {
5375
if (source === 'code-default') return t('aiModelStatus.sourceCodeDefault');
5476
if (source === 'inherits-conversational') return t('aiModelStatus.sourceInherits');
5577
if (source.startsWith('env:')) return t('aiModelStatus.sourcePinned', { source: source.slice(4) });
78+
// service-ai's `attributeSource` returns the bare token 'unknown' when the
79+
// adapter cannot report a model — reachable, and it used to render as raw
80+
// English here (objectui#2886).
81+
if (source === 'unknown') return t('aiModelStatus.sourceUnknown');
5682
return source;
5783
}
5884

@@ -136,9 +162,33 @@ export function CloudAiModelStatus({ properties }: CloudAiModelStatusProps) {
136162
const { report } = state;
137163
const setOverrides = Object.entries(report.overrides).filter(([, v]) => v != null);
138164

165+
// The summary the server sends is English-only and unlocalizable at source
166+
// (see `EffectiveModelReport.summary`), so compose the same sentence from the
167+
// structured fields. `sourceLabel` already yields exactly the two clauses the
168+
// server hand-rolls: "pinned by X" / "code default (no env override)" for the
169+
// conversational half, and "same as build/ask" for an unpinned structured
170+
// model — so no new source vocabulary is needed here.
171+
const unknownModel = t('aiModelStatus.modelUnknown');
172+
139173
return (
140174
<div className="space-y-4" data-ai-model-status="ready">
141-
<p className="text-sm text-foreground">{report.summary}</p>
175+
<p className="text-sm text-foreground" data-testid="ai-model-summary">
176+
{t('aiModelStatus.summary', {
177+
conversational: report.conversational.model ?? unknownModel,
178+
conversationalSource: sourceLabel(report.conversational.source, t),
179+
structured: report.structured.model ?? unknownModel,
180+
structuredSource: sourceLabel(report.structured.source, t),
181+
})}
182+
{report.routing ? (
183+
<>
184+
{' '}
185+
{t('aiModelStatus.summaryRouting', {
186+
free: report.routing.free,
187+
paid: report.routing.paid,
188+
})}
189+
</>
190+
) : null}
191+
</p>
142192

143193
<div className="rounded-md border border-border px-3">
144194
<ModelRow

packages/i18n/src/locales/ar.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2475,6 +2475,10 @@ const ar = {
24752475
manageEnvironments: "إدارة البيئات",
24762476
},
24772477
aiModelStatus: {
2478+
summary: "يستخدم الإنشاء / السؤال {{conversational}} ({{conversationalSource}})؛ ويستخدم الإخراج المهيكل {{structured}} ({{structuredSource}}).",
2479+
summaryRouting: "سياسة التوجيه: الخطط المجانية ← {{free}}، الخطط المدفوعة ← {{paid}}.",
2480+
modelUnknown: "غير معروف",
2481+
sourceUnknown: "لم يبلّغ عنه المحوّل",
24782482
rowConversational: "نموذج Build / Ask",
24792483
rowStructured: "مُهيكل (blueprint / seed)",
24802484
rowReasoning: "مستوى الاستدلال",

packages/i18n/src/locales/de.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2475,6 +2475,10 @@ const de = {
24752475
manageEnvironments: "Umgebungen verwalten",
24762476
},
24772477
aiModelStatus: {
2478+
summary: "Erstellen / Fragen nutzt {{conversational}} ({{conversationalSource}}); strukturiert nutzt {{structured}} ({{structuredSource}}).",
2479+
summaryRouting: "Routing-Richtlinie: kostenlose Tarife → {{free}}, kostenpflichtige Tarife → {{paid}}.",
2480+
modelUnknown: "unbekannt",
2481+
sourceUnknown: "vom Adapter nicht gemeldet",
24782482
rowConversational: "Build-/Ask-Modell",
24792483
rowStructured: "Strukturiert (Blueprint / Seed)",
24802484
rowReasoning: "Reasoning-Aufwand",

packages/i18n/src/locales/en.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2576,6 +2576,10 @@ const en = {
25762576
manageEnvironments: 'Manage environments',
25772577
},
25782578
aiModelStatus: {
2579+
summary: 'Build / Ask uses {{conversational}} ({{conversationalSource}}); structured uses {{structured}} ({{structuredSource}}).',
2580+
summaryRouting: 'Routing policy: free plans → {{free}}, paid plans → {{paid}}.',
2581+
modelUnknown: 'unknown',
2582+
sourceUnknown: 'not reported by the adapter',
25792583
rowConversational: 'Build / Ask model',
25802584
rowStructured: 'Structured (blueprint / seed)',
25812585
rowReasoning: 'Reasoning effort',

packages/i18n/src/locales/es.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2475,6 +2475,10 @@ const es = {
24752475
manageEnvironments: "Gestionar entornos",
24762476
},
24772477
aiModelStatus: {
2478+
summary: "Crear / Preguntar usa {{conversational}} ({{conversationalSource}}); estructurado usa {{structured}} ({{structuredSource}}).",
2479+
summaryRouting: "Política de enrutamiento: planes gratuitos → {{free}}, planes de pago → {{paid}}.",
2480+
modelUnknown: "desconocido",
2481+
sourceUnknown: "no informado por el adaptador",
24782482
rowConversational: "Modelo Build / Ask",
24792483
rowStructured: "Estructurado (blueprint / seed)",
24802484
rowReasoning: "Esfuerzo de razonamiento",

packages/i18n/src/locales/fr.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2475,6 +2475,10 @@ const fr = {
24752475
manageEnvironments: "Gérer les environnements",
24762476
},
24772477
aiModelStatus: {
2478+
summary: "Créer / Demander utilise {{conversational}} ({{conversationalSource}}) ; structuré utilise {{structured}} ({{structuredSource}}).",
2479+
summaryRouting: "Politique de routage : offres gratuites → {{free}}, offres payantes → {{paid}}.",
2480+
modelUnknown: "inconnu",
2481+
sourceUnknown: "non communiqué par l'adaptateur",
24782482
rowConversational: "Modèle Build / Ask",
24792483
rowStructured: "Structuré (blueprint / seed)",
24802484
rowReasoning: "Effort de raisonnement",

packages/i18n/src/locales/ja.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2475,6 +2475,10 @@ const ja = {
24752475
manageEnvironments: "環境を管理",
24762476
},
24772477
aiModelStatus: {
2478+
summary: "ビルド / 質問は {{conversational}}({{conversationalSource}})、構造化出力は {{structured}}({{structuredSource}})を使用します。",
2479+
summaryRouting: "ルーティングポリシー: 無料プラン → {{free}}、有料プラン → {{paid}}。",
2480+
modelUnknown: "不明",
2481+
sourceUnknown: "アダプターから報告なし",
24782482
rowConversational: "Build / Ask モデル",
24792483
rowStructured: "構造化(ブループリント / シード)",
24802484
rowReasoning: "推論の強度",

packages/i18n/src/locales/ko.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2475,6 +2475,10 @@ const ko = {
24752475
manageEnvironments: "환경 관리",
24762476
},
24772477
aiModelStatus: {
2478+
summary: "빌드 / 질문은 {{conversational}}({{conversationalSource}}), 구조화 출력은 {{structured}}({{structuredSource}})을(를) 사용합니다.",
2479+
summaryRouting: "라우팅 정책: 무료 요금제 → {{free}}, 유료 요금제 → {{paid}}.",
2480+
modelUnknown: "알 수 없음",
2481+
sourceUnknown: "어댑터가 보고하지 않음",
24782482
rowConversational: "Build / Ask 모델",
24792483
rowStructured: "구조화(블루프린트 / 시드)",
24802484
rowReasoning: "추론 강도",

0 commit comments

Comments
 (0)