diff --git a/.changeset/ai-model-status-summary-i18n.md b/.changeset/ai-model-status-summary-i18n.md new file mode 100644 index 000000000..ada75a889 --- /dev/null +++ b/.changeset/ai-model-status-summary-i18n.md @@ -0,0 +1,45 @@ +--- +"@object-ui/i18n": patch +"@object-ui/app-shell": patch +--- + +fix(i18n): compose the AI-model diagnostics summary client-side instead of rendering the server's English string (objectui#2886) + +`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; +- `service-ai/src/routes/ai-routes.ts:395` declares `handler: async () => …` — + it takes **no request argument**, so it cannot read `Accept-Language` even + though `createAuthenticatedFetch` has been sending it since objectui#1319. + +But no server change is needed, because every ingredient of the sentence is +already in the structured payload: `conversational.model`, +`conversational.source`, `structured.model`, `structured.pinned`, and +`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 from those fields. `sourceLabel()` already +produced exactly the two clauses the server hand-rolls — "pinned by X" / +"code default (no env override)", and "same as build/ask" for an unpinned +structured model — so no new source vocabulary was required. + +**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**. +It is 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 (`summary`, `summaryRouting`, `modelUnknown`, +`sourceUnknown`), so the full-parity guard from objectui#2909 stays green. + +The panel had **no test coverage at all**; it now has five, mutation-tested by +restoring `

{report.summary}

` — which fails four of them. diff --git a/packages/app-shell/src/console/diagnostics/CloudAiModelStatus.test.tsx b/packages/app-shell/src/console/diagnostics/CloudAiModelStatus.test.tsx new file mode 100644 index 000000000..54826090e --- /dev/null +++ b/packages/app-shell/src/console/diagnostics/CloudAiModelStatus.test.tsx @@ -0,0 +1,119 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The AI-model diagnostics panel composes its own summary line (objectui#2886). + * + * `service-ai` builds `report.summary` as a hard-coded English template literal + * and its route handler takes no request argument, so it cannot honour + * `Accept-Language` even though the client has been sending it since + * objectui#1319. Every ingredient of that sentence is already in the structured + * fields, so the panel assembles a localized equivalent and ignores `summary`. + * + * These tests pin the two things that regressed before: the English string must + * not reach the DOM, and `routing` — which the server always sent but the client + * never declared — must be surfaced. + */ +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import { I18nProvider, createI18n } from '@object-ui/i18n'; + +vi.mock('@object-ui/auth', () => ({ + createAuthenticatedFetch: () => vi.fn().mockImplementation(() => Promise.resolve(mockResponse())), +})); + +let payload: Record; +const mockResponse = () => + ({ ok: true, status: 200, json: () => Promise.resolve(payload) }) as unknown as Response; + +import { CloudAiModelStatus } from './CloudAiModelStatus'; + +/** The English sentence the server sends and this panel must NOT render. */ +const SERVER_SUMMARY = + 'build/ask uses gpt-5 (pinned by OS_AI_MODEL); structured uses gpt-5-mini. ' + + 'Routing policy: free plans → gpt-5-mini, paid plans → gpt-5.'; + +function baseReport(extra: Record = {}) { + return { + conversational: { model: 'gpt-5', source: 'env:OS_AI_MODEL' }, + structured: { model: 'gpt-5-mini', pinned: true, source: 'env:OS_AI_MODEL_STRUCTURED' }, + reasoningEffort: { effective: 'medium', source: 'code-default' }, + adapter: 'vercel', + overrides: { OS_AI_MODEL: 'gpt-5' }, + summary: SERVER_SUMMARY, + ...extra, + }; +} + +function renderPanel(lang = 'en') { + // `I18nProvider` takes a config/instance, not a `language` prop — passing one + // is silently ignored at runtime, so pin the language on the instance. + const instance = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false }); + return render( + + + , + ); +} + +beforeEach(() => { + payload = baseReport(); +}); +afterEach(cleanup); + +describe('CloudAiModelStatus — summary is composed client-side', () => { + it('renders a localized summary and never the server’s English string', async () => { + renderPanel('en'); + const line = await screen.findByTestId('ai-model-summary'); + + expect(line).toHaveTextContent('gpt-5'); + expect(line).toHaveTextContent('gpt-5-mini'); + // The regression: `

{report.summary}

` put this verbatim on screen for + // every locale. + expect(line.textContent).not.toBe(SERVER_SUMMARY); + expect(document.body.textContent).not.toContain('build/ask uses'); + }); + + it('surfaces the routing policy the client type used to drop', async () => { + payload = baseReport({ routing: { free: 'gpt-5-mini', paid: 'gpt-5' } }); + renderPanel('en'); + const line = await screen.findByTestId('ai-model-summary'); + // Routing only ever appeared inside the English summary, so non-English + // admins could not see it at all. + expect(line).toHaveTextContent(/Routing policy/i); + expect(line).toHaveTextContent('gpt-5-mini'); + }); + + it('omits the routing clause when the host does not inject one', async () => { + renderPanel('en'); + const line = await screen.findByTestId('ai-model-summary'); + expect(line).not.toHaveTextContent(/Routing policy/i); + }); + + it('translates the summary — a zh render shares no sentence text with en', async () => { + payload = baseReport({ routing: { free: 'gpt-5-mini', paid: 'gpt-5' } }); + const { unmount } = renderPanel('en'); + const en = (await screen.findByTestId('ai-model-summary')).textContent ?? ''; + unmount(); + + renderPanel('zh'); + const zh = (await screen.findByTestId('ai-model-summary')).textContent ?? ''; + + expect(zh).not.toBe(en); + expect(zh).toMatch(/[一-鿿]/); + // Model ids are identifiers, not prose — they must survive translation. + expect(zh).toContain('gpt-5'); + }); + + it('falls back to a translated placeholder when the adapter reports no model', async () => { + payload = baseReport({ + conversational: { model: undefined, source: 'unknown' }, + structured: { model: undefined, pinned: false, source: 'inherits-conversational' }, + }); + renderPanel('en'); + const line = await screen.findByTestId('ai-model-summary'); + // `attributeSource` emits the bare token 'unknown'; it used to render raw. + expect(line.textContent).not.toMatch(/\bunknown\s*\)/); + expect(line).toHaveTextContent(/not reported by the adapter/i); + }); +}); diff --git a/packages/app-shell/src/console/diagnostics/CloudAiModelStatus.tsx b/packages/app-shell/src/console/diagnostics/CloudAiModelStatus.tsx index 444e0748d..ba1bbc138 100644 --- a/packages/app-shell/src/console/diagnostics/CloudAiModelStatus.tsx +++ b/packages/app-shell/src/console/diagnostics/CloudAiModelStatus.tsx @@ -31,6 +31,28 @@ interface EffectiveModelReport { adapter: string; provider?: string; overrides: Record; + /** + * Plan→model routing for this deploy, injected by the host (objectos-runtime + * owns `planToAiModel`; service-ai stays plan-agnostic), so the report says + * what EACH tier gets rather than only the model the live adapter happens to + * hold. Omitted by hosts with no plan concept. + * + * The server has always sent this; the client just never declared it, so the + * only place it surfaced was inside the English `summary` string — meaning + * non-English admins could not see the routing policy at all (objectui#2886). + */ + routing?: { free: string; paid: string }; + /** + * Server-composed one-line summary. **Deliberately not rendered.** + * + * It is assembled in `service-ai`'s `buildEffectiveModelReport` as a + * hard-coded English template literal, and the route handler there takes no + * request argument at all — so it cannot read `Accept-Language` even though + * `createAuthenticatedFetch` has been sending it since objectui#1319. Every + * ingredient of the sentence is already in the structured fields above, so + * this panel composes its own localized version instead. Kept in the type + * because it is part of the wire contract, not because it is used. + */ summary: string; } @@ -53,6 +75,10 @@ function sourceLabel(source: string, t: TFn): string { if (source === 'code-default') return t('aiModelStatus.sourceCodeDefault'); if (source === 'inherits-conversational') return t('aiModelStatus.sourceInherits'); if (source.startsWith('env:')) return t('aiModelStatus.sourcePinned', { source: source.slice(4) }); + // service-ai's `attributeSource` returns the bare token 'unknown' when the + // adapter cannot report a model — reachable, and it used to render as raw + // English here (objectui#2886). + if (source === 'unknown') return t('aiModelStatus.sourceUnknown'); return source; } @@ -136,9 +162,33 @@ export function CloudAiModelStatus({ properties }: CloudAiModelStatusProps) { const { report } = state; const setOverrides = Object.entries(report.overrides).filter(([, v]) => v != null); + // The summary the server sends is English-only and unlocalizable at source + // (see `EffectiveModelReport.summary`), so compose the same sentence from the + // structured fields. `sourceLabel` already yields exactly the two clauses the + // server hand-rolls: "pinned by X" / "code default (no env override)" for the + // conversational half, and "same as build/ask" for an unpinned structured + // model — so no new source vocabulary is needed here. + const unknownModel = t('aiModelStatus.modelUnknown'); + return (
-

{report.summary}

+

+ {t('aiModelStatus.summary', { + conversational: report.conversational.model ?? unknownModel, + conversationalSource: sourceLabel(report.conversational.source, t), + structured: report.structured.model ?? unknownModel, + structuredSource: sourceLabel(report.structured.source, t), + })} + {report.routing ? ( + <> + {' '} + {t('aiModelStatus.summaryRouting', { + free: report.routing.free, + paid: report.routing.paid, + })} + + ) : null} +