From 9d0e0165aa70dbd9f64cf419fd5833a8b663d651 Mon Sep 17 00:00:00 2001 From: NekoPunch Date: Thu, 3 Sep 2026 23:13:43 -0700 Subject: [PATCH 1/2] fix(i18n): code-ize health signals, drop zh defaults Health signals carried zh product copy from the producer; the en presenter reverse-mapped it by exact string match, so any wording edit silently broke English rendering. Signals now carry stable message codes and structured details, both locales map codes in the presenter catalog, and raw capability or connection diagnostics no longer leak machine or wrong-locale text into Settings surfaces. The ui stream/projection seams lose their silent 'zh' locale defaults so callers must thread the real locale. Generated-by: Claude Code Generated-by: OpenCode --- .../main/__tests__/assistant-stream.test.ts | 13 +- .../main/__tests__/health-center-copy.test.ts | 81 ++++- .../main/__tests__/thinking-stream.test.ts | 11 +- .../main/__tests__/tool-output-stream.test.ts | 8 +- .../settings-provider-copy.ts | 3 + .../renderer/locales/settings-health-copy.ts | 284 +++++++++++------- .../settings/settings-pages.stories.tsx | 38 +-- packages/core/src/__tests__/health.test.ts | 17 ++ packages/core/src/health.ts | 148 ++++++--- .../__tests__/live-turn-projection.test.ts | 21 +- packages/ui/src/__tests__/materialize.test.ts | 22 +- .../streaming-display-redaction.test.ts | 25 +- packages/ui/src/assistant-stream.ts | 10 +- packages/ui/src/chat-view.tsx | 4 +- packages/ui/src/live-turn-projection.ts | 6 +- packages/ui/src/thinking-stream.ts | 10 +- packages/ui/src/tool-activity/copy.ts | 12 + .../ui/src/tool-activity/preview-utils.ts | 2 +- .../ui/src/tool-activity/result-projection.ts | 6 +- packages/ui/src/tool-format.ts | 12 +- packages/ui/src/tool-output-stream.ts | 6 +- 21 files changed, 508 insertions(+), 231 deletions(-) diff --git a/apps/desktop/src/main/__tests__/assistant-stream.test.ts b/apps/desktop/src/main/__tests__/assistant-stream.test.ts index e0db00c5d5..083f6795c4 100644 --- a/apps/desktop/src/main/__tests__/assistant-stream.test.ts +++ b/apps/desktop/src/main/__tests__/assistant-stream.test.ts @@ -22,9 +22,18 @@ import { describe, it } from 'node:test'; import { ASSISTANT_MAX_DELTA_CHARS, ASSISTANT_MAX_TOTAL_CHARS, - applyAssistantComplete, - applyAssistantDelta, + applyAssistantComplete as applyAssistantCompleteWithLocale, + applyAssistantDelta as applyAssistantDeltaWithLocale, } from '@maka/ui/assistant-stream'; +// Tests exercise stream mechanics, not copy; pin zh so markers stay verbatim. +const applyAssistantDelta = (prev: string, delta: string, options?: Partial[2]>) => + applyAssistantDeltaWithLocale(prev, delta, { locale: 'zh-CN', ...options }); +const applyAssistantComplete = (text: string, options?: Partial[1]>) => + applyAssistantCompleteWithLocale(text, { locale: 'zh-CN', ...options }); + + + + function visibleDeltaResult(result: ReturnType) { return { diff --git a/apps/desktop/src/main/__tests__/health-center-copy.test.ts b/apps/desktop/src/main/__tests__/health-center-copy.test.ts index 6ab57b2855..5bf1fd1284 100644 --- a/apps/desktop/src/main/__tests__/health-center-copy.test.ts +++ b/apps/desktop/src/main/__tests__/health-center-copy.test.ts @@ -19,6 +19,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; +import { connectionLastTestMessageDisplay } from '../../renderer/features/connection-settings/index.js'; import { getHealthCenterCopy } from '../../renderer/locales/settings-health-copy.js'; test('labels blocker counts as global across filtered health views', () => { @@ -35,15 +36,15 @@ test('labels blocker counts as global across filtered health views', () => { test('Traditional Chinese health copy localizes structured signal text', () => { const copy = getHealthCenterCopy('zh-TW'); const signal = { - id: 'connection:test', - label: '測試 运行态', + id: 'connection:test:runtime', + label: '測試', scope: 'llm_connection' as const, layer: 'validation' as const, status: 'ok' as const, source: 'connection_test' as const, checkedAt: 1, - message: '凭据与端点验证已通过。', - detail: '这是连接验证结果,不代表发送、流式输出或中断通路已经运行通过。', + message: 'validation_passed' as const, + detail: { kind: 'validation_scope_note' as const }, blocksSend: false, }; assert.equal(copy.layers.configuration.label, '設定'); @@ -51,3 +52,75 @@ test('Traditional Chinese health copy localizes structured signal text', () => { assert.equal(copy.signalMessage(signal), '憑證與端點驗證已通過。'); assert.match(copy.signalDetail(signal) ?? '', /串流輸出/); }); + +const signal = (overrides: Partial): import('@maka/core/health').HealthSignal => ({ + id: 'connection:demo', + label: 'Demo', + scope: 'llm_connection', + layer: 'configuration', + status: 'info', + source: 'settings', + checkedAt: 0, + message: 'not_default_source', + ...overrides, +}); + +test('renders configuration message codes distinctly in both locales', () => { + const zh = getHealthCenterCopy('zh-CN'); + const en = getHealthCenterCopy('en'); + assert.equal(zh.signalMessage(signal({ message: 'not_default_source' })), '不是工作区的默认模型来源。'); + assert.equal(en.signalMessage(signal({ message: 'not_default_source' })), 'Not the workspace default model source.'); + assert.equal(zh.signalMessage(signal({ message: 'no_models_enabled' })), '没有启用任何模型。'); + assert.equal(en.signalMessage(signal({ message: 'no_models_enabled' })), 'No models are enabled on this connection.'); +}); + +test('renders runtime probe details from structured params, not string parsing', () => { + const detail = { kind: 'runtime_probe_result', modelId: 'claude-sonnet-5', latencyMs: 812, errorClass: 'timeout' } as const; + assert.equal( + getHealthCenterCopy('zh-CN').signalDetail(signal({ detail })), + '模型=claude-sonnet-5 · 延迟=812ms · 错误类型=timeout', + ); + assert.equal( + getHealthCenterCopy('en').signalDetail(signal({ detail })), + 'Model=claude-sonnet-5 · Latency=812ms · Error type=timeout', + ); +}); + +test('degrades capability reasons safely in both locales', () => { + const unknown = { kind: 'capability_reason', reason: 'Discord rejected the Bot Token.' } as const; + assert.equal( + getHealthCenterCopy('zh-CN').signalDetail(signal({ detail: unknown })), + '状态详情请见对应设置页。', + ); + assert.equal( + getHealthCenterCopy('en').signalDetail(signal({ detail: unknown })), + 'See the corresponding settings page for details.', + ); +}); + +test('maps connection test error classes without exposing machine tokens', () => { + const auth = { kind: 'last_test_error_class', errorClass: 'auth' } as const; + assert.equal(getHealthCenterCopy('zh-CN').signalDetail(signal({ detail: auth })), '鉴权失败'); + assert.equal(getHealthCenterCopy('en').signalDetail(signal({ detail: auth })), 'Authentication failed'); + + const unknown = { kind: 'last_test_message', text: 'future_error_class' } as const; + assert.equal( + getHealthCenterCopy('zh-CN').signalDetail(signal({ detail: unknown })), + '连接测试状态暂时无法显示,请重新测试。', + ); + assert.equal( + getHealthCenterCopy('en').signalDetail(signal({ detail: unknown })), + 'The connection test status is temporarily unavailable. Test again.', + ); +}); + +test('maps connection test error classes in connection details', () => { + assert.equal(connectionLastTestMessageDisplay('auth', 'zh-CN'), '鉴权失败'); + assert.equal(connectionLastTestMessageDisplay('auth', 'en'), 'Authentication failed'); +}); + +test('suffixes runtime signal labels per locale from the id, not the producer', () => { + const runtime = signal({ id: 'connection:demo:runtime', label: 'Demo' }); + assert.equal(getHealthCenterCopy('zh-CN').signalLabel(runtime), 'Demo 运行态'); + assert.equal(getHealthCenterCopy('en').signalLabel(runtime), 'Demo runtime'); +}); diff --git a/apps/desktop/src/main/__tests__/thinking-stream.test.ts b/apps/desktop/src/main/__tests__/thinking-stream.test.ts index 8d33f1df80..2420f1c972 100644 --- a/apps/desktop/src/main/__tests__/thinking-stream.test.ts +++ b/apps/desktop/src/main/__tests__/thinking-stream.test.ts @@ -21,7 +21,16 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; -import { applyThinkingComplete, applyThinkingDelta } from '@maka/ui'; +import { applyThinkingComplete as applyThinkingCompleteWithLocale, applyThinkingDelta as applyThinkingDeltaWithLocale } from '@maka/ui'; +// Tests exercise stream mechanics, not copy; pin zh so markers stay verbatim. +const applyThinkingDelta = (prev: string, delta: string, options?: Partial[2]>) => + applyThinkingDeltaWithLocale(prev, delta, { locale: 'zh-CN', ...options }); +const applyThinkingComplete = (text: string, options?: Partial[1]>) => + applyThinkingCompleteWithLocale(text, { locale: 'zh-CN', ...options }); + + + + describe('applyThinkingDelta — secondary redaction', () => { it('masks raw `Authorization: Bearer ...` text before storing', () => { diff --git a/apps/desktop/src/main/__tests__/tool-output-stream.test.ts b/apps/desktop/src/main/__tests__/tool-output-stream.test.ts index ca66c39051..14c5110eab 100644 --- a/apps/desktop/src/main/__tests__/tool-output-stream.test.ts +++ b/apps/desktop/src/main/__tests__/tool-output-stream.test.ts @@ -48,9 +48,15 @@ import { TOOL_STREAM_MAX_CHUNKS, TOOL_STREAM_MAX_CHUNK_CHARS, TOOL_STREAM_MAX_TOTAL_CHARS, - applyToolOutputChunk, + applyToolOutputChunk as applyToolOutputChunkWithLocale, type ToolOutputChunk, } from '@maka/ui'; +// Tests exercise stream mechanics, not copy; pin zh so markers stay verbatim. +const applyToolOutputChunk = (prev: Parameters[0], chunk: Parameters[1], options?: Partial[2]>) => + applyToolOutputChunkWithLocale(prev, chunk, { locale: 'zh-CN', ...options }); + + + function chunk(seq: number, text: string, stream: 'stdout' | 'stderr' = 'stdout', redacted = false): ToolOutputChunk { return { seq, text, stream, redacted, createdAt: 1_700_000_000_000 + seq }; diff --git a/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts b/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts index da6a223f72..2b0c1fe1ec 100644 --- a/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts +++ b/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts @@ -191,6 +191,7 @@ const zhCopy = { filterMatches: (count: number) => (count === 0 ? '没有匹配的结果' : `${count} 个匹配结果`), connectionStatuses: { retired: '已停用 · 请删除', reauth: '需要重新登录', disabledFailed: '暂不可用 · 上次连接失败', disabled: '暂不可用', failed: '上次连接失败' }, lastTest: { + auth: '鉴权失败', timeout: '请求超时', provider_unavailable: '模型服务返回错误', network: '网络错误', invalid_response: '模型服务返回错误', unknown: '连接测试失败', '连接已验证': '连接已验证', '鉴权失败': '鉴权失败', '请求超时': '请求超时', '网络错误': '网络错误', '模型服务返回错误': '模型服务返回错误', '连接测试失败': '连接测试失败', 'connection verified': '连接已验证', 'authentication failed': '鉴权失败', 'request timed out': '请求超时', 'network error': '网络错误', 'provider returned an error': '模型服务返回错误', 'connection test failed': '连接测试失败', 'claude oauth 未登录。': 'Claude OAuth 未登录。', 'claude oauth 本地凭据读取失败。': 'Claude OAuth 本地凭据读取失败。', 'claude oauth 需要重新登录。': 'Claude OAuth 需要重新登录。', 'claude oauth 已登录。': 'Claude OAuth 已登录。', 'claude oauth 已退出登录。': 'Claude OAuth 已退出登录。', @@ -360,6 +361,7 @@ const zhTwCopy = { filterMatches: (count: number) => (count === 0 ? '沒有符合的結果' : `${count} 個符合結果`), connectionStatuses: { retired: '已停用 · 請刪除', reauth: '需要重新登入', disabledFailed: '暫不可用 · 上次連線失敗', disabled: '暫不可用', failed: '上次連線失敗' }, lastTest: { + auth: '鑑權失敗', timeout: '請求超時', provider_unavailable: '模型服務回傳錯誤', network: '網路錯誤', invalid_response: '模型服務回傳錯誤', unknown: '連線測試失敗', '连接已验证': '連線已驗證', '鉴权失败': '鑑權失敗', '请求超时': '請求超時', '网络错误': '網路錯誤', '模型服务返回错误': '模型服務回傳錯誤', '连接测试失败': '連線測試失敗', 'connection verified': '連線已驗證', 'authentication failed': '鑑權失敗', 'request timed out': '請求超時', 'network error': '網路錯誤', 'provider returned an error': '模型服務回傳錯誤', 'connection test failed': '連線測試失敗', 'claude oauth 未登录。': 'Claude OAuth 未登入。', 'claude oauth 本地凭据读取失败。': 'Claude OAuth 本地憑據讀取失敗。', 'claude oauth 需要重新登录。': 'Claude OAuth 需要重新登入。', 'claude oauth 已登录。': 'Claude OAuth 已登入。', 'claude oauth 已退出登录。': 'Claude OAuth 已退出登入。', @@ -530,6 +532,7 @@ const enCopy: ProviderSettingsCopy = { filterMatches: (count: number) => (count === 0 ? 'No matches' : count === 1 ? '1 match' : `${count} matches`), connectionStatuses: { retired: 'Retired · delete it', reauth: 'Sign-in required', disabledFailed: 'Unavailable · last connection failed', disabled: 'Unavailable', failed: 'Last connection failed' }, lastTest: { + auth: 'Authentication failed', timeout: 'Request timed out', provider_unavailable: 'Model service returned an error', network: 'Network error', invalid_response: 'Model service returned an error', unknown: 'Connection test failed', '连接已验证': 'Connection verified', '鉴权失败': 'Authentication failed', '请求超时': 'Request timed out', '网络错误': 'Network error', '模型服务返回错误': 'Model service returned an error', '连接测试失败': 'Connection test failed', 'connection verified': 'Connection verified', 'authentication failed': 'Authentication failed', 'request timed out': 'Request timed out', 'network error': 'Network error', 'provider returned an error': 'Model service returned an error', 'connection test failed': 'Connection test failed', 'claude oauth 未登录。': 'Claude OAuth is signed out.', 'claude oauth 本地凭据读取失败。': 'Could not read local Claude OAuth credentials.', 'claude oauth 需要重新登录。': 'Claude OAuth requires sign-in.', 'claude oauth 已登录。': 'Claude OAuth is signed in.', 'claude oauth 已退出登录。': 'Claude OAuth signed out.', diff --git a/apps/desktop/src/renderer/locales/settings-health-copy.ts b/apps/desktop/src/renderer/locales/settings-health-copy.ts index f1b9ed34f0..57947b6e11 100644 --- a/apps/desktop/src/renderer/locales/settings-health-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-health-copy.ts @@ -18,7 +18,15 @@ */ import type { StatusSemantic } from '@maka/ui'; -import type { HealthSignal, HealthSignalLayer, HealthSignalSource, HealthSignalStatus } from '@maka/core/health'; +import type { + HealthConnectionTestErrorClass, + HealthSignal, + HealthSignalDetail, + HealthSignalLayer, + HealthSignalMessageCode, + HealthSignalSource, + HealthSignalStatus, +} from '@maka/core/health'; import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; @@ -86,60 +94,6 @@ const layersZhTw: HealthCenterCopy['layers'] = { storage: { label: '儲存空間', description: '工作區檔案、JSONL、SQLite 和其他本機儲存空間的健康狀態。' }, }; -const healthMessageZhTw: Readonly> = { - '连接已关闭。': '連線已關閉。', - '等待选择默认模型。': '等待選擇預設模型。', - '凭据与端点验证已通过。': '憑證與端點驗證已通過。', - '连接需要重新修复认证。': '連線需要重新完成驗證。', - '上次连接验证失败。': '上次連線驗證失敗。', - '没有启用任何模型。': '尚未啟用任何模型。', - '不是工作区的默认模型来源。': '不是工作區的預設模型來源。', - '等待验证连接。': '等待驗證連線。', - '等待完成发送运行态探测。': '等待完成傳送執行狀態探測。', - '能力门禁已满足。': '能力門檻已滿足。', - '能力已关闭或暂停。': '能力已關閉或暫停。', - '等待补齐能力配置。': '等待完成能力設定。', - '能力被必要系统权限阻塞。': '能力受到必要系統權限阻擋。', - '能力运行态探测处于降级状态。': '能力執行狀態探測目前處於降級狀態。', - '最近一次发送已完成。': '最近一次傳送已完成。', - '最近一次发送已由用户停止。': '最近一次傳送已由使用者停止。', - '最近一次发送失败。': '最近一次傳送失敗。', -}; - -const healthDetailZhTw: Readonly> = { - '这是连接验证结果,不代表发送、流式输出或中断通路已经运行通过。': '這是連線驗證結果,不代表傳送、串流輸出或中斷路徑已實際執行成功。', - '在 设置 · 模型 的连接详情里启用至少一个模型后才能使用该连接。': '請在「設定・模型」的連線詳細資料中啟用至少一個模型,才能使用此連線。', - '在任务中显式选择该连接的模型即可正常使用;新对话的默认模型在 设置 · 通用 配置。': '在任務中明確選擇此連線的模型即可使用;新對話的預設模型可在「設定・一般」中設定。', - '凭据验证与真实发送、流式输出、中断通路是两层健康信号。': '憑證驗證與實際傳送、串流輸出、中斷路徑是兩層不同的健康訊號。', - '该能力当前已关闭。': '此能力目前已關閉。', - '等待填写平台凭据。': '等待填寫平台憑證。', - '仅 macOS 系统权限可探测。': '只能探測 macOS 系統權限。', - '系统未提供可直接读取的授权状态。': '系統未提供可直接讀取的授權狀態。', - '状态详情请见对应设置页。': '狀態詳細資料請參閱對應的設定頁。', -}; - -function healthSignalLabelZhTw(signal: HealthSignal): string { - return signal.label.endsWith(' 运行态') - ? `${signal.label.slice(0, -' 运行态'.length)} 執行狀態` - : signal.label; -} - -function healthSignalMessageZhTw(signal: HealthSignal): string { - return healthMessageZhTw[signal.message] - ?? (/[\u3400-\u9fff]/u.test(signal.message) ? '健康狀態已更新。' : signal.message); -} - -function healthSignalDetailZhTw(signal: HealthSignal): string | undefined { - if (!signal.detail) return undefined; - const runtimeDetail = /^模型=(.*?) · 延迟=(\d+)ms(?: · 错误类型=(.*))?$/u.exec(signal.detail); - if (runtimeDetail) { - const [, model, latency, errorClass] = runtimeDetail; - return `模型=${model} · 延遲=${latency}ms${errorClass ? ` · 錯誤類型=${errorClass}` : ''}`; - } - return healthDetailZhTw[signal.detail] - ?? (/[\u3400-\u9fff]/u.test(signal.detail) ? '詳細資料請參閱對應的設定頁。' : signal.detail); -} - const layersEn: HealthCenterCopy['layers'] = { configuration: { label: 'Configuration', description: 'Whether required settings are complete.' }, validation: { label: 'Validation', description: 'Credential and endpoint connectivity results. A passing validation does not prove the send path works.' }, @@ -167,9 +121,9 @@ const SETTINGS_HEALTH_COPY = { scopes: { app: '应用', llm_connection: 'LLM 连接', bot: '机器人', capability: '能力', storage: '存储' }, sources: { connection_test: '连接测试', capability_snapshot: '能力快照', permission_snapshot: '权限快照', runtime_probe: '运行态探测', settings: '设置', storage: '本地存储' }, source: '来源:', blocksSend: '阻塞发送', blocksCapability: '阻塞能力', - signalLabel: (signal) => signal.label, - signalMessage: (signal) => signal.message, - signalDetail: (signal) => signal.detail, + signalLabel: (signal) => (signal.id.endsWith(':runtime') ? `${signal.label} 运行态` : signal.label), + signalMessage: (signal) => signalMessagesZh[signal.message], + signalDetail: (signal) => signalDetailZh(signal.detail), }, 'zh-TW': { loading: '正在載入健康快照', readFailed: '無法讀取健康快照', noData: '健康服務未返回資料。', readAgain: '重新讀取', @@ -186,9 +140,9 @@ const SETTINGS_HEALTH_COPY = { scopes: { app: '應用', llm_connection: 'LLM 連線', bot: '機器人', capability: '能力', storage: '儲存' }, sources: { connection_test: '連線測試', capability_snapshot: '能力快照', permission_snapshot: '權限快照', runtime_probe: '執行態探測', settings: '設定', storage: '本地儲存' }, source: '來源:', blocksSend: '阻塞傳送', blocksCapability: '阻塞能力', - signalLabel: healthSignalLabelZhTw, - signalMessage: healthSignalMessageZhTw, - signalDetail: healthSignalDetailZhTw, + signalLabel: (signal) => (signal.id.endsWith(':runtime') ? `${signal.label} 執行狀態` : signal.label), + signalMessage: (signal) => signalMessagesZhTw[signal.message], + signalDetail: (signal) => signalDetailZhTw(signal.detail), }, en: { loading: 'Loading health snapshot', readFailed: 'Could not read health snapshot', noData: 'The health service returned no data.', readAgain: 'Read again', @@ -205,9 +159,9 @@ const SETTINGS_HEALTH_COPY = { scopes: { app: 'App', llm_connection: 'LLM connection', bot: 'Bot', capability: 'Capability', storage: 'Storage' }, sources: { connection_test: 'Connection test', capability_snapshot: 'Capability snapshot', permission_snapshot: 'Permission snapshot', runtime_probe: 'Runtime probe', settings: 'Settings', storage: 'Local storage' }, source: 'Source: ', blocksSend: 'Blocks sending', blocksCapability: 'Blocks capability', - signalLabel: englishSignalLabel, - signalMessage: englishSignalMessage, - signalDetail: englishSignalDetail, + signalLabel: (signal) => (signal.id.endsWith(':runtime') ? `${signal.label} runtime` : signal.label), + signalMessage: (signal) => signalMessagesEn[signal.message], + signalDetail: (signal) => signalDetailEn(signal.detail), }, } satisfies UiCatalog; @@ -215,57 +169,167 @@ export function getHealthCenterCopy(locale: UiLocale): HealthCenterCopy { return SETTINGS_HEALTH_COPY[locale]; } -function englishSignalLabel(signal: HealthSignal): string { - if (signal.id.endsWith(':runtime')) return `${signal.label.replace(/\s*运行态$/, '')} runtime`; - return signal.label; -} +const signalMessagesZh: Record = { + connection_disabled: '连接已关闭。', + awaiting_default_model: '等待选择默认模型。', + validation_passed: '凭据与端点验证已通过。', + needs_reauth: '连接需要重新修复认证。', + validation_failed: '上次连接验证失败。', + no_models_enabled: '没有启用任何模型。', + not_default_source: '不是工作区的默认模型来源。', + awaiting_validation: '等待验证连接。', + runtime_probe_pending: '等待完成发送运行态探测。', + send_completed: '最近一次发送已完成。', + send_aborted: '最近一次发送已由用户停止。', + send_failed: '最近一次发送失败。', + capability_ok: '能力门禁已满足。', + capability_paused: '能力已关闭或暂停。', + capability_not_configured: '等待补齐能力配置。', + capability_denied: '能力被必要系统权限阻塞。', + capability_degraded: '能力运行态探测处于降级状态。', +}; -function englishSignalMessage(signal: HealthSignal): string { - if (signal.scope === 'llm_connection') { - if (signal.layer === 'configuration') { - // Three-way split matching the producer's configuration states - // (packages/core/src/health.ts) — the message string is the anchor, - // the same way the runtime_probe branch below parses the producer's - // detail. Falling back on status alone described an enabled - // non-default connection as disabled. - if (signal.message === '不是工作区的默认模型来源。') { - return 'Not the workspace default model source.'; - } - if (signal.message === '没有启用任何模型。') { - return 'No models are enabled on this connection.'; - } - return signal.status === 'info' ? 'Connection is disabled.' : 'Select a default model.'; - } - if (signal.layer === 'runtime_probe') { - return { ok: 'The latest send completed.', info: 'The latest send was stopped by the user.', warning: 'The latest send failed.', error: 'The latest send failed.', unknown: 'Waiting for a send-path runtime probe.' }[signal.status]; - } - return { ok: 'Credentials and endpoint validation passed.', info: 'Connection validation needs attention.', warning: 'The latest connection validation failed.', error: 'The connection needs authentication repair.', unknown: 'Waiting to validate the connection.' }[signal.status]; - } - if (signal.scope === 'capability' || signal.scope === 'bot') { - return { ok: 'Capability requirements are satisfied.', info: 'The capability is disabled or paused.', warning: 'Capability configuration is incomplete.', error: 'The capability is blocked or degraded.', unknown: 'Capability state is unknown.' }[signal.status]; +const signalMessagesZhTw: Record = { + connection_disabled: '連線已關閉。', + awaiting_default_model: '等待選擇預設模型。', + validation_passed: '憑證與端點驗證已通過。', + needs_reauth: '連線需要重新完成驗證。', + validation_failed: '上次連線驗證失敗。', + no_models_enabled: '尚未啟用任何模型。', + not_default_source: '不是工作區的預設模型來源。', + awaiting_validation: '等待驗證連線。', + runtime_probe_pending: '等待完成傳送執行狀態探測。', + send_completed: '最近一次傳送已完成。', + send_aborted: '最近一次傳送已由使用者停止。', + send_failed: '最近一次傳送失敗。', + capability_ok: '能力門檻已滿足。', + capability_paused: '能力已關閉或暫停。', + capability_not_configured: '等待完成能力設定。', + capability_denied: '能力受到必要系統權限阻擋。', + capability_degraded: '能力執行狀態探測目前處於降級狀態。', +}; + +const signalMessagesEn: Record = { + connection_disabled: 'Connection is disabled.', + awaiting_default_model: 'Select a default model.', + validation_passed: 'Credentials and endpoint validation passed.', + needs_reauth: 'The connection needs authentication repair.', + validation_failed: 'The latest connection validation failed.', + no_models_enabled: 'No models are enabled on this connection.', + not_default_source: 'Not the workspace default model source.', + awaiting_validation: 'Waiting to validate the connection.', + runtime_probe_pending: 'Waiting for a send-path runtime probe.', + send_completed: 'The latest send completed.', + send_aborted: 'The latest send was stopped by the user.', + send_failed: 'The latest send failed.', + capability_ok: 'Capability requirements are satisfied.', + capability_paused: 'The capability is disabled or paused.', + capability_not_configured: 'Capability configuration is incomplete.', + capability_denied: 'The capability is blocked by a required system permission.', + capability_degraded: 'The capability runtime probe is degraded.', +}; + +const connectionTestErrorMessages = { + 'zh-CN': { + auth: '鉴权失败', + timeout: '请求超时', + provider_unavailable: '模型服务返回错误', + network: '网络错误', + invalid_response: '模型服务返回错误', + unknown: '连接测试失败', + }, + 'zh-TW': { + auth: '驗證失敗', + timeout: '請求逾時', + provider_unavailable: '模型服務傳回錯誤', + network: '網路錯誤', + invalid_response: '模型服務傳回錯誤', + unknown: '連線測試失敗', + }, + en: { + auth: 'Authentication failed', + timeout: 'Request timed out', + provider_unavailable: 'Model service returned an error', + network: 'Network error', + invalid_response: 'Model service returned an error', + unknown: 'Connection test failed', + }, +} satisfies UiCatalog>; + +function signalDetailZh(detail: HealthSignalDetail | undefined): string | undefined { + if (!detail) return undefined; + switch (detail.kind) { + case 'validation_scope_note': + return '这是连接验证结果,不代表发送、流式输出或中断通路已经运行通过。'; + case 'no_models_enabled_hint': + return '在 设置 · 模型 的连接详情里启用至少一个模型后才能使用该连接。'; + case 'not_default_source_hint': + return '在任务中显式选择该连接的模型即可正常使用;新对话的默认模型在 设置 · 通用 配置。'; + case 'runtime_probe_layers_note': + return '凭据验证与真实发送、流式输出、中断通路是两层健康信号。'; + case 'runtime_probe_result': + return [ + `模型=${detail.modelId}`, + `延迟=${detail.latencyMs}ms`, + ...(detail.errorClass ? [`错误类型=${detail.errorClass}`] : []), + ].join(' · '); + case 'capability_reason': + return '状态详情请见对应设置页。'; + case 'last_test_error_class': + return connectionTestErrorMessages['zh-CN'][detail.errorClass]; + case 'last_test_message': + return '连接测试状态暂时无法显示,请重新测试。'; } - return { ok: 'The health check passed.', info: 'Review this health signal.', warning: 'This health signal needs attention.', error: 'This health signal reports an error.', unknown: 'Health state is unknown.' }[signal.status]; } -function englishSignalDetail(signal: HealthSignal): string | undefined { - if (!signal.detail) return undefined; - if (signal.scope === 'llm_connection' && signal.layer === 'validation' && signal.status === 'ok') { - return 'This validates the connection only; it does not prove send, streaming, or interruption paths have run successfully.'; +function signalDetailZhTw(detail: HealthSignalDetail | undefined): string | undefined { + if (!detail) return undefined; + switch (detail.kind) { + case 'validation_scope_note': + return '這是連線驗證結果,不代表傳送、串流輸出或中斷路徑已實際執行成功。'; + case 'no_models_enabled_hint': + return '請在「設定・模型」的連線詳細資料中啟用至少一個模型,才能使用此連線。'; + case 'not_default_source_hint': + return '在任務中明確選擇此連線的模型即可使用;新對話的預設模型可在「設定・一般」中設定。'; + case 'runtime_probe_layers_note': + return '憑證驗證與實際傳送、串流輸出、中斷路徑是兩層不同的健康訊號。'; + case 'runtime_probe_result': + return [ + `模型=${detail.modelId}`, + `延遲=${detail.latencyMs}ms`, + ...(detail.errorClass ? [`錯誤類型=${detail.errorClass}`] : []), + ].join(' · '); + case 'capability_reason': + return '狀態詳細資料請參閱對應的設定頁。'; + case 'last_test_error_class': + return connectionTestErrorMessages['zh-TW'][detail.errorClass]; + case 'last_test_message': + return '連線測試狀態暫時無法顯示,請重新測試。'; } - if (signal.scope === 'llm_connection' && signal.layer === 'runtime_probe') { - const model = signal.detail.match(/模型=([^·]+)/)?.[1]?.trim(); - const latency = signal.detail.match(/延迟=([^·]+)/)?.[1]?.trim(); - const errorClass = signal.detail.match(/错误类型=([^·]+)/)?.[1]?.trim(); - const parts = [model && `Model=${model}`, latency && `Latency=${latency}`, errorClass && `Error type=${errorClass}`].filter(Boolean); - return parts.length > 0 ? parts.join(' · ') : 'Runtime details are available in Usage settings.'; - } - if (signal.scope === 'llm_connection' && signal.layer === 'configuration') { - if (signal.message === '不是工作区的默认模型来源。') { - return 'Models on this connection stay usable when selected explicitly in a task; the default model for new chats lives in Settings · General.'; - } - if (signal.message === '没有启用任何模型。') { +} + +function signalDetailEn(detail: HealthSignalDetail | undefined): string | undefined { + if (!detail) return undefined; + switch (detail.kind) { + case 'validation_scope_note': + return 'This validates the connection only; it does not prove send, streaming, or interruption paths have run successfully.'; + case 'no_models_enabled_hint': return "Enable at least one model in this connection's detail view under Settings · Models."; - } + case 'not_default_source_hint': + return 'Models on this connection stay usable when selected explicitly in a task; the default model for new chats lives in Settings · General.'; + case 'runtime_probe_layers_note': + return 'Credential validation and real send, streaming, and interruption paths are two separate health layers.'; + case 'runtime_probe_result': + return [ + `Model=${detail.modelId}`, + `Latency=${detail.latencyMs}ms`, + ...(detail.errorClass ? [`Error type=${detail.errorClass}`] : []), + ].join(' · '); + case 'capability_reason': + return 'See the corresponding settings page for details.'; + case 'last_test_error_class': + return connectionTestErrorMessages.en[detail.errorClass]; + case 'last_test_message': + return 'The connection test status is temporarily unavailable. Test again.'; } - return 'See the corresponding settings page for details.'; } diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index 032e326f80..d7a94df48b 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -589,16 +589,6 @@ const capabilitySnapshot: CapabilitySnapshotCollection = { }; const healthSignals: HealthSignal[] = [ - { - id: 'app:config', - label: '应用配置', - scope: 'app', - layer: 'configuration', - status: 'ok', - source: 'settings', - checkedAt: NOW - 60_000, - message: '配置文件可读写,schema 版本为最新。', - }, { id: 'conn:zai-live', label: 'Z.AI Live', @@ -607,8 +597,8 @@ const healthSignals: HealthSignal[] = [ status: 'ok', source: 'connection_test', checkedAt: NOW - 12 * 60_000, - message: '连接测试通过,延迟 210ms。', - detail: '验证通过只代表凭据可用,实际可用性仍需运行态探测确认。', + message: 'validation_passed', + detail: { kind: 'validation_scope_note' }, }, { id: 'conn:openai-review', @@ -618,8 +608,8 @@ const healthSignals: HealthSignal[] = [ status: 'error', source: 'connection_test', checkedAt: NOW - 3 * 60_000, - message: '连接测试失败:HTTP 401 invalid_api_key。', - detail: '凭据已失效或被吊销,请在「模型」页重新填写 API Key 后再次测试。', + message: 'needs_reauth', + detail: { kind: 'last_test_message', text: 'HTTP 401 invalid_api_key' }, blocksSend: true, }, { @@ -630,7 +620,7 @@ const healthSignals: HealthSignal[] = [ status: 'info', source: 'capability_snapshot', checkedAt: NOW - 60_000, - message: '功能已开启,但仍以逐次审批模式运行。', + message: 'capability_paused', relatedCapabilityId: 'computer_use', }, { @@ -641,21 +631,11 @@ const healthSignals: HealthSignal[] = [ status: 'warning', source: 'runtime_probe', checkedAt: NOW - 5 * 60_000, - message: '探测超时,已回落到只读观察模式。', - detail: 'maka-cu 未在 3000ms 内完成握手;下一次探测会在功能被调用时自动触发。', + message: 'capability_degraded', + detail: { kind: 'capability_reason', reason: 'maka-cu 未在 3000ms 内完成握手;下一次探测会在功能被调用时自动触发。' }, relatedCapabilityId: 'computer_use', blocksCapability: true, }, - { - id: 'storage:sessions', - label: '会话存储', - scope: 'storage', - layer: 'storage', - status: 'ok', - source: 'storage', - checkedAt: NOW - 60_000, - message: 'SQLite 库可写,WAL 检查点正常。', - }, ]; const healthSnapshot: HealthSnapshot = buildHealthSnapshot(NOW - 45_000, healthSignals); @@ -2625,8 +2605,8 @@ export const HealthCenter: Story = { expect(errorFilter).toHaveAttribute('aria-pressed', 'true'); expect(canvas.getByText('OpenAI Review')).toBeInTheDocument(); expect(canvas.queryByText('Z.AI Live')).not.toBeInTheDocument(); - expect(canvas.getByText('全部健康信号中,1/6 条会阻塞发送')).toBeInTheDocument(); - expect(canvas.getByText('全部健康信号中,1/6 条会阻塞能力')).toBeInTheDocument(); + expect(canvas.getByText('全部健康信号中,1/4 条会阻塞发送')).toBeInTheDocument(); + expect(canvas.getByText('全部健康信号中,1/4 条会阻塞能力')).toBeInTheDocument(); }); await userEvent.click(errorFilter); await waitFor(() => { diff --git a/packages/core/src/__tests__/health.test.ts b/packages/core/src/__tests__/health.test.ts index 0e751851a0..3bbe88ac7a 100644 --- a/packages/core/src/__tests__/health.test.ts +++ b/packages/core/src/__tests__/health.test.ts @@ -44,6 +44,23 @@ describe('HealthSignal contract', () => { assert.strictEqual(result.source, 'connection_test'); }); + test('separates connection test error classes from legacy diagnostics', () => { + const coded = healthSignalFromConnection( + connection({ lastTestStatus: 'needs_reauth', lastTestMessage: 'auth' }), + 20, + ); + assert.deepStrictEqual(coded.detail, { kind: 'last_test_error_class', errorClass: 'auth' }); + + const legacy = healthSignalFromConnection( + connection({ lastTestStatus: 'error', lastTestMessage: 'HTTP 502 upstream failure' }), + 20, + ); + assert.deepStrictEqual(legacy.detail, { + kind: 'last_test_message', + text: 'HTTP 502 upstream failure', + }); + }); + test('a missing default model warns only when the workspace has no default target', () => { // The catalog projects `defaultModel` onto exactly one connection (the // default target). With a default configured elsewhere, an enabled diff --git a/packages/core/src/health.ts b/packages/core/src/health.ts index e88582cc56..f7a43bce67 100644 --- a/packages/core/src/health.ts +++ b/packages/core/src/health.ts @@ -46,6 +46,46 @@ export type HealthSignalSource = | 'settings' | 'storage'; +export type HealthSignalMessageCode = + | 'connection_disabled' + | 'awaiting_default_model' + | 'validation_passed' + | 'needs_reauth' + | 'validation_failed' + | 'no_models_enabled' + | 'not_default_source' + | 'awaiting_validation' + | 'runtime_probe_pending' + | 'send_completed' + | 'send_aborted' + | 'send_failed' + | 'capability_ok' + | 'capability_paused' + | 'capability_not_configured' + | 'capability_denied' + | 'capability_degraded'; + +export type HealthConnectionTestErrorClass = + | 'auth' + | 'timeout' + | 'provider_unavailable' + | 'network' + | 'invalid_response' + | 'unknown'; + +export type HealthSignalDetail = + | { kind: 'validation_scope_note' } + | { kind: 'no_models_enabled_hint' } + | { kind: 'not_default_source_hint' } + | { kind: 'runtime_probe_layers_note' } + | { kind: 'runtime_probe_result'; modelId: string; latencyMs: number; errorClass?: string } + // Raw machine token from the capability snapshot; the presenter maps the + // known vocabulary per locale and falls back to a generic hint. + | { kind: 'capability_reason'; reason: string } + | { kind: 'last_test_error_class'; errorClass: HealthConnectionTestErrorClass } + // Legacy connection-test diagnostic; the presenter uses locale-specific fallback copy. + | { kind: 'last_test_message'; text: string }; + export interface HealthSignal { id: string; label: string; @@ -54,8 +94,8 @@ export interface HealthSignal { status: HealthSignalStatus; source: HealthSignalSource; checkedAt: number; - message: string; - detail?: string; + message: HealthSignalMessageCode; + detail?: HealthSignalDetail; relatedCapabilityId?: CapabilityId; blocksSend?: boolean; blocksCapability?: boolean; @@ -151,7 +191,7 @@ export function healthSignalFromConnection( status: 'info', source: 'settings', checkedAt, - message: '连接已关闭。', + message: 'connection_disabled', blocksSend: false, }; } @@ -165,7 +205,7 @@ export function healthSignalFromConnection( status: 'warning', source: 'settings', checkedAt, - message: '等待选择默认模型。', + message: 'awaiting_default_model', blocksSend: true, }; } @@ -179,8 +219,8 @@ export function healthSignalFromConnection( status: 'ok', source: 'connection_test', checkedAt: timeFromIso(connection.lastTestAt) ?? checkedAt, - message: '凭据与端点验证已通过。', - detail: '这是连接验证结果,不代表发送、流式输出或中断通路已经运行通过。', + message: 'validation_passed', + detail: { kind: 'validation_scope_note' }, blocksSend: false, }; } @@ -194,8 +234,10 @@ export function healthSignalFromConnection( status: 'error', source: 'connection_test', checkedAt: timeFromIso(connection.lastTestAt) ?? checkedAt, - message: '连接需要重新修复认证。', - detail: connection.lastTestMessage, + message: 'needs_reauth', + ...(connection.lastTestMessage + ? { detail: connectionLastTestDetail(connection.lastTestMessage) } + : {}), blocksSend: true, }; } @@ -209,8 +251,10 @@ export function healthSignalFromConnection( status: 'warning', source: 'connection_test', checkedAt: timeFromIso(connection.lastTestAt) ?? checkedAt, - message: '上次连接验证失败。', - detail: connection.lastTestMessage, + message: 'validation_failed', + ...(connection.lastTestMessage + ? { detail: connectionLastTestDetail(connection.lastTestMessage) } + : {}), blocksSend: true, }; } @@ -230,8 +274,8 @@ export function healthSignalFromConnection( status: 'warning', source: 'settings', checkedAt, - message: '没有启用任何模型。', - detail: '在 设置 · 模型 的连接详情里启用至少一个模型后才能使用该连接。', + message: 'no_models_enabled', + detail: { kind: 'no_models_enabled_hint' }, blocksSend: false, }; } @@ -243,8 +287,8 @@ export function healthSignalFromConnection( status: 'info', source: 'settings', checkedAt, - message: '不是工作区的默认模型来源。', - detail: '在任务中显式选择该连接的模型即可正常使用;新对话的默认模型在 设置 · 通用 配置。', + message: 'not_default_source', + detail: { kind: 'not_default_source_hint' }, blocksSend: false, }; } @@ -257,7 +301,7 @@ export function healthSignalFromConnection( status: 'unknown', source: 'connection_test', checkedAt, - message: '等待验证连接。', + message: 'awaiting_validation', blocksSend: false, }; } @@ -272,14 +316,14 @@ export function healthSignalFromConnectionRuntime( if (!latestRuntimeProbe) { return { id: `connection:${connection.slug}:runtime`, - label: `${connection.name} 运行态`, + label: connection.name, scope: 'llm_connection', layer: 'runtime_probe', status: 'unknown', source: 'runtime_probe', checkedAt, - message: '等待完成发送运行态探测。', - detail: '凭据验证与真实发送、流式输出、中断通路是两层健康信号。', + message: 'runtime_probe_pending', + detail: { kind: 'runtime_probe_layers_note' }, blocksSend: false, }; } @@ -287,7 +331,7 @@ export function healthSignalFromConnectionRuntime( const status = runtimeStatusToHealth(latestRuntimeProbe.status); return { id: `connection:${connection.slug}:runtime`, - label: `${connection.name} 运行态`, + label: connection.name, scope: 'llm_connection', layer: 'runtime_probe', status, @@ -342,41 +386,42 @@ function healthLayerFromCapability(capability: CapabilitySnapshot): HealthSignal return 'feature'; } -function capabilityMessage(readiness: CapabilityReadinessState): string { +function capabilityMessage(readiness: CapabilityReadinessState): HealthSignalMessageCode { switch (readiness) { case 'enabled': - return '能力门禁已满足。'; + return 'capability_ok'; case 'paused': - return '能力已关闭或暂停。'; + return 'capability_paused'; case 'not_configured': - return '等待补齐能力配置。'; + return 'capability_not_configured'; case 'denied': - return '能力被必要系统权限阻塞。'; + return 'capability_denied'; case 'degraded': - return '能力运行态探测处于降级状态。'; + return 'capability_degraded'; } } -function capabilityDetail(capability: CapabilitySnapshot): string | undefined { - return userVisibleCapabilityReason( - capability.runtimeProbe.reason ?? capability.feature.reason ?? capability.configuration.reason, - ); +function capabilityDetail(capability: CapabilitySnapshot): HealthSignalDetail | undefined { + const reason = ( + capability.runtimeProbe.reason ?? + capability.feature.reason ?? + capability.configuration.reason + )?.trim(); + return reason ? { kind: 'capability_reason', reason } : undefined; } -function userVisibleCapabilityReason(reason: string | undefined): string | undefined { - const raw = reason?.trim(); - if (!raw) return undefined; - switch (raw) { - case 'disabled': - return '该能力当前已关闭。'; - case 'missing platform credentials': - return '等待填写平台凭据。'; - case 'macOS TCC only': - return '仅 macOS 系统权限可探测。'; - case 'no Electron API for per-target Apple Events TCC status': - return '系统未提供可直接读取的授权状态。'; +function connectionLastTestDetail(message: string): HealthSignalDetail { + const normalized = message.trim().toLowerCase(); + switch (normalized) { + case 'auth': + case 'timeout': + case 'provider_unavailable': + case 'network': + case 'invalid_response': + case 'unknown': + return { kind: 'last_test_error_class', errorClass: normalized }; default: - return /[\u3400-\u9fff]/.test(raw) ? raw : '状态详情请见对应设置页。'; + return { kind: 'last_test_message', text: message }; } } @@ -397,19 +442,22 @@ function runtimeStatusToHealth(status: UsageLogRow['status']): HealthSignalStatu } } -function runtimeProbeMessage(status: UsageLogRow['status']): string { +function runtimeProbeMessage(status: UsageLogRow['status']): HealthSignalMessageCode { switch (status) { case 'success': - return '最近一次发送已完成。'; + return 'send_completed'; case 'aborted': - return '最近一次发送已由用户停止。'; + return 'send_aborted'; case 'error': - return '最近一次发送失败。'; + return 'send_failed'; } } -function runtimeProbeDetail(row: UsageLogRow): string { - const parts = [`模型=${row.modelId}`, `延迟=${row.latencyMs}ms`]; - if (row.errorClass) parts.push(`错误类型=${row.errorClass}`); - return parts.join(' · '); +function runtimeProbeDetail(row: UsageLogRow): HealthSignalDetail { + return { + kind: 'runtime_probe_result', + modelId: row.modelId, + latencyMs: row.latencyMs, + ...(row.errorClass ? { errorClass: row.errorClass } : {}), + }; } diff --git a/packages/ui/src/__tests__/live-turn-projection.test.ts b/packages/ui/src/__tests__/live-turn-projection.test.ts index 29fc03fc7b..6d50f0c751 100644 --- a/packages/ui/src/__tests__/live-turn-projection.test.ts +++ b/packages/ui/src/__tests__/live-turn-projection.test.ts @@ -21,7 +21,7 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import { encodeToolStepProgress } from '@maka/core/events'; import { - applyLiveTurnEvent, + applyLiveTurnEvent as applyLiveTurnEventWithLocale, armLiveTurn, confirmLiveTurn, reconcileTerminalLiveTurn, @@ -32,6 +32,25 @@ import { materializeTurns, overlayLiveTurn, type ToolActivityItem } from '../mat import { redactSecrets } from '../redact.js'; import { getConversationCopy } from '../conversation-copy.js'; +import type { SessionEvent } from '@maka/core/events'; +type LiveTurnContentEvent = Extract; +// Tests exercise projection logic, not copy; pin zh so markers stay verbatim. +function applyLiveTurnEvent( + current: LiveTurnProjection | undefined, + event: LiveTurnContentEvent, +): LiveTurnProjection; +function applyLiveTurnEvent( + current: LiveTurnProjection | undefined, + event: SessionEvent, +): LiveTurnProjection | undefined; +function applyLiveTurnEvent( + current: LiveTurnProjection | undefined, + event: SessionEvent, +): LiveTurnProjection | undefined { + return applyLiveTurnEventWithLocale(current, event, 'zh-CN'); +} + + // A client that just sent cannot read "has my turn started" off session status: // it is the same before the turn starts and after it ends. The arm carries // `unconfirmed` until the authority says something about THAT turn, which is diff --git a/packages/ui/src/__tests__/materialize.test.ts b/packages/ui/src/__tests__/materialize.test.ts index a7a2a76a3a..ecb1434932 100644 --- a/packages/ui/src/__tests__/materialize.test.ts +++ b/packages/ui/src/__tests__/materialize.test.ts @@ -28,10 +28,30 @@ import { type TurnTimelineItem, } from "../materialize.js"; import { - applyLiveTurnEvent, + applyLiveTurnEvent as applyLiveTurnEventWithLocale, armLiveTurn, } from "../live-turn-projection.js"; +import type { LiveTurnProjection } from '../live-turn-projection.js'; +import type { SessionEvent } from '@maka/core/events'; +type LiveTurnContentEvent = Extract; +// Tests exercise projection logic, not copy; pin zh so markers stay verbatim. +function applyLiveTurnEvent( + current: LiveTurnProjection | undefined, + event: LiveTurnContentEvent, +): LiveTurnProjection; +function applyLiveTurnEvent( + current: LiveTurnProjection | undefined, + event: SessionEvent, +): LiveTurnProjection | undefined; +function applyLiveTurnEvent( + current: LiveTurnProjection | undefined, + event: SessionEvent, +): LiveTurnProjection | undefined { + return applyLiveTurnEventWithLocale(current, event, 'zh-CN'); +} + + const originalUser = { type: "user" as const, id: "original", diff --git a/packages/ui/src/__tests__/streaming-display-redaction.test.ts b/packages/ui/src/__tests__/streaming-display-redaction.test.ts index 3f9ebc1ffd..e3545a1d51 100644 --- a/packages/ui/src/__tests__/streaming-display-redaction.test.ts +++ b/packages/ui/src/__tests__/streaming-display-redaction.test.ts @@ -22,12 +22,32 @@ import { describe, it } from 'node:test'; import { redactSecrets } from '../redact.js'; import { applyAssistantComplete, applyAssistantDelta } from '../assistant-stream.js'; import { applyThinkingComplete, applyThinkingDelta } from '../thinking-stream.js'; -import { applyLiveTurnEvent } from '../live-turn-projection.js'; +import { applyLiveTurnEvent as applyLiveTurnEventWithLocale } from '../live-turn-projection.js'; import { appendStreamingDisplayRedaction, createStreamingDisplayRedactionState, } from '../streaming-display-redaction.js'; +import type { LiveTurnProjection } from '../live-turn-projection.js'; +import type { SessionEvent } from '@maka/core/events'; +type LiveTurnContentEvent = Extract; +// Tests exercise projection logic, not copy; pin zh so markers stay verbatim. +function applyLiveTurnEvent( + current: LiveTurnProjection | undefined, + event: LiveTurnContentEvent, +): LiveTurnProjection; +function applyLiveTurnEvent( + current: LiveTurnProjection | undefined, + event: SessionEvent, +): LiveTurnProjection | undefined; +function applyLiveTurnEvent( + current: LiveTurnProjection | undefined, + event: SessionEvent, +): LiveTurnProjection | undefined { + return applyLiveTurnEventWithLocale(current, event, 'zh-CN'); +} + + function streamBySizes(input: string, sizes: readonly number[]): void { let projection: ReturnType | undefined; let source = ''; @@ -164,12 +184,14 @@ describe('streaming display redaction', () => { for (const apply of [applyAssistantDelta, applyThinkingDelta]) { const initialState = createStreamingDisplayRedactionState(); const opener = apply('', 'Authorization:', { + locale: 'zh-CN' as const, maxDeltaChars: 128, maxTotalChars: 512, redactionState: initialState, }); const secret = `Bearer ${'s'.repeat(5_000)}`; const truncated = apply(opener.text, secret, { + locale: 'zh-CN' as const, maxDeltaChars: 128, maxTotalChars: 512, redactionState: opener.redactionState, @@ -183,6 +205,7 @@ describe('streaming display redaction', () => { ); const total = apply('', 'safe '.repeat(200), { + locale: 'zh-CN' as const, maxDeltaChars: 2_000, maxTotalChars: 128, redactionState: createStreamingDisplayRedactionState(), diff --git a/packages/ui/src/assistant-stream.ts b/packages/ui/src/assistant-stream.ts index 38474c5484..2cb10ab8e9 100644 --- a/packages/ui/src/assistant-stream.ts +++ b/packages/ui/src/assistant-stream.ts @@ -56,7 +56,7 @@ export const ASSISTANT_MAX_TOTAL_CHARS = 256 * 1024; export interface ApplyAssistantOptions extends ApplyStreamOptions { /** Resolved UI locale for user-visible truncation markers. */ - locale?: UiLocale; + locale: UiLocale; } export type ApplyAssistantResult = ApplyStreamResult; @@ -65,9 +65,9 @@ export type ApplyAssistantResult = ApplyStreamResult; export function applyAssistantDelta( prev: string, rawDelta: string, - options: ApplyAssistantOptions = {}, + options: ApplyAssistantOptions, ): ApplyAssistantResult { - const copy = getSharedUiCopy(options.locale ?? 'zh-CN').stream; + const copy = getSharedUiCopy(options.locale).stream; return applyStreamDelta(prev, rawDelta, { maxDeltaChars: options.maxDeltaChars ?? ASSISTANT_MAX_DELTA_CHARS, maxTotalChars: options.maxTotalChars ?? ASSISTANT_MAX_TOTAL_CHARS, @@ -83,11 +83,11 @@ export function applyAssistantDelta( /** Apply a `text_complete` final payload (replace, total cap only). */ export function applyAssistantComplete( rawText: string, - options: Pick = {}, + options: Pick, ): ApplyAssistantResult { return applyStreamComplete(rawText, { maxTotalChars: options.maxTotalChars ?? ASSISTANT_MAX_TOTAL_CHARS, recovery: 'head', - totalMarker: getSharedUiCopy(options.locale ?? 'zh-CN').stream.assistantTailTruncated, + totalMarker: getSharedUiCopy(options.locale).stream.assistantTailTruncated, }); } diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index 0e2e146298..da53472516 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -965,11 +965,11 @@ export function ChatView(props: { export function DeepResearchProgressPanel({ run, onContinue, - copy = getConversationCopy('zh-CN').chat.deepResearchProgress, + copy, }: { run: DeepResearchClientProgress; onContinue?: (run: DeepResearchClientProgress) => void; - copy?: ReturnType['chat']['deepResearchProgress']; + copy: ReturnType['chat']['deepResearchProgress']; }) { const completedItems = run.checklist.filter( (item) => item.status === 'completed' || item.status === 'skipped', diff --git a/packages/ui/src/live-turn-projection.ts b/packages/ui/src/live-turn-projection.ts index 504dbcb106..f926290b3e 100644 --- a/packages/ui/src/live-turn-projection.ts +++ b/packages/ui/src/live-turn-projection.ts @@ -192,17 +192,17 @@ export function confirmLiveTurn( export function applyLiveTurnEvent( current: LiveTurnProjection | undefined, event: LiveTurnContentEvent, - locale?: UiLocale, + locale: UiLocale, ): LiveTurnProjection; export function applyLiveTurnEvent( current: LiveTurnProjection | undefined, event: SessionEvent, - locale?: UiLocale, + locale: UiLocale, ): LiveTurnProjection | undefined; export function applyLiveTurnEvent( current: LiveTurnProjection | undefined, event: SessionEvent, - locale: UiLocale = 'zh-CN', + locale: UiLocale, ): LiveTurnProjection | undefined { if (event.type === 'steering_message') { const prior = current?.turnId === event.turnId diff --git a/packages/ui/src/thinking-stream.ts b/packages/ui/src/thinking-stream.ts index de98f7a65f..9c29e8a47e 100644 --- a/packages/ui/src/thinking-stream.ts +++ b/packages/ui/src/thinking-stream.ts @@ -65,7 +65,7 @@ export const THINKING_MAX_TOTAL_CHARS = 32 * 1024; export interface ApplyThinkingOptions extends ApplyStreamOptions { /** Resolved UI locale for user-visible truncation markers. */ - locale?: UiLocale; + locale: UiLocale; } export type ApplyThinkingResult = ApplyStreamResult; @@ -74,9 +74,9 @@ export type ApplyThinkingResult = ApplyStreamResult; export function applyThinkingDelta( prev: string, rawDelta: string, - options: ApplyThinkingOptions = {}, + options: ApplyThinkingOptions, ): ApplyThinkingResult { - const copy = getSharedUiCopy(options.locale ?? 'zh-CN').stream; + const copy = getSharedUiCopy(options.locale).stream; return applyStreamDelta(prev, rawDelta, { maxDeltaChars: options.maxDeltaChars ?? THINKING_MAX_DELTA_CHARS, maxTotalChars: options.maxTotalChars ?? THINKING_MAX_TOTAL_CHARS, @@ -96,11 +96,11 @@ export function applyThinkingDelta( */ export function applyThinkingComplete( rawText: string, - options: ApplyThinkingOptions = {}, + options: ApplyThinkingOptions, ): ApplyThinkingResult { return applyStreamComplete(rawText, { maxTotalChars: options.maxTotalChars ?? THINKING_MAX_TOTAL_CHARS, recovery: 'tail', - totalMarker: getSharedUiCopy(options.locale ?? 'zh-CN').stream.thinkingHeadTruncated, + totalMarker: getSharedUiCopy(options.locale).stream.thinkingHeadTruncated, }); } diff --git a/packages/ui/src/tool-activity/copy.ts b/packages/ui/src/tool-activity/copy.ts index 5f73a5e3a2..b3f3929fe4 100644 --- a/packages/ui/src/tool-activity/copy.ts +++ b/packages/ui/src/tool-activity/copy.ts @@ -110,6 +110,9 @@ export interface ToolActivityCopy { genericAction: string; genericTitle: string; genericDescription: string; + fallbackLabel: string; + namedAction: (label: string) => string; + namedTitle: (label: string) => string; count: (count: number) => string; technicalDetails: string; groupId: string; @@ -255,6 +258,9 @@ const TOOL_ACTIVITY_COPY = { genericAction: '启用工具能力', genericTitle: '工具能力已启用', genericDescription: '现在可以使用这组工具。', + fallbackLabel: '工具', + namedAction: (label) => `启用 ${label}`, + namedTitle: (label) => `${label} 已启用`, count: (n) => `${n} 项能力可用`, technicalDetails: '技术详情', groupId: '工具组', @@ -357,6 +363,9 @@ const TOOL_ACTIVITY_COPY = { loadTools: { displayName: '啟用能力', genericAction: '啟用工具能力', + fallbackLabel: '工具', + namedAction: (label) => `啟用 ${label}`, + namedTitle: (label) => `${label} 已啟用`, genericTitle: '工具能力已啟用', genericDescription: '現在可以使用這組工具。', count: (n) => `${n} 項能力可用`, @@ -460,6 +469,9 @@ const TOOL_ACTIVITY_COPY = { genericAction: 'Enable tool capabilities', genericTitle: 'Tool capabilities enabled', genericDescription: 'This tool group is ready to use.', + fallbackLabel: 'Tools', + namedAction: (label) => `Enable ${label}`, + namedTitle: (label) => `${label} enabled`, count: (n) => `${n} ${n === 1 ? 'capability' : 'capabilities'} available`, technicalDetails: 'Technical details', groupId: 'Group', diff --git a/packages/ui/src/tool-activity/preview-utils.ts b/packages/ui/src/tool-activity/preview-utils.ts index 73efe24e39..61a1db266f 100644 --- a/packages/ui/src/tool-activity/preview-utils.ts +++ b/packages/ui/src/tool-activity/preview-utils.ts @@ -47,7 +47,7 @@ export function formatDuration(ms: number | undefined): string | null { return `${minutes}m ${seconds}s`; } -export function formatUserVisibleToolText(text: string, locale: UiLocale = 'zh-CN'): string { +export function formatUserVisibleToolText(text: string, locale: UiLocale): string { return text.replace(/\bUser denied permission(?: request)?\b|用户已拒绝权限请求/g, getToolActivityCopy(locale).permissionDenied); } diff --git a/packages/ui/src/tool-activity/result-projection.ts b/packages/ui/src/tool-activity/result-projection.ts index 3a3774ebad..7c23c2feee 100644 --- a/packages/ui/src/tool-activity/result-projection.ts +++ b/packages/ui/src/tool-activity/result-projection.ts @@ -114,7 +114,7 @@ function resultHasCapturedStreams(result: ToolActivityItem['result']): boolean { export function withLiveStreamFallback( result: NonNullable, chunks: ToolActivityItem['outputChunks'] | undefined, - options?: { truncated?: boolean; locale?: UiLocale }, + options: { truncated?: boolean; locale: UiLocale }, ): NonNullable { if (result.kind !== 'terminal' && result.kind !== 'shell_run') return result; if (resultHasCapturedStreams(result)) return result; @@ -134,7 +134,7 @@ export function withLiveStreamFallback( else stdout += chunk.text; } const truncated = existing?.mode === 'pipes' && existing.stdoutTruncated === true - || options?.truncated === true; + || options.truncated === true; // Empty redacted/truncated live buffer still carries diagnosis — do not // early-return and drop "已脱敏" / "输出已截断". if (!stdout && !stderr && !anyRedacted && !truncated) return result; @@ -142,7 +142,7 @@ export function withLiveStreamFallback( // Match live stream's "[已脱敏]" marker when a chunk was redacted // (including empty bodies that only suppressed secrets). if (anyRedacted) { - const marker = getToolActivityCopy(options?.locale ?? 'zh-CN').output.redacted; + const marker = getToolActivityCopy(options.locale).output.redacted; if (stdout.length > 0) stdout = `${stdout}${stdout.endsWith('\n') ? '' : '\n'}${marker}`; else if (stderr.length > 0) stderr = `${stderr}${stderr.endsWith('\n') ? '' : '\n'}${marker}`; else stdout = marker; diff --git a/packages/ui/src/tool-format.ts b/packages/ui/src/tool-format.ts index 4d319bd30c..ed40dca398 100644 --- a/packages/ui/src/tool-format.ts +++ b/packages/ui/src/tool-format.ts @@ -88,17 +88,11 @@ export function describeLoadToolResult( }; } - const label = suppliedLabel ?? (locale === 'en' ? 'Tools' : '工具'); - const enableLabel = locale === 'en' ? 'Enable' : locale === 'zh-CN' ? '启用' : '啟用'; - const enabledLabel = locale === 'en' ? 'enabled' : locale === 'zh-CN' ? '已启用' : '已啟用'; + const label = suppliedLabel ?? copy.fallbackLabel; return { kind, - actionLabel: suppliedLabel - ? `${enableLabel} ${suppliedLabel}` - : copy.genericAction, - title: suppliedLabel - ? `${suppliedLabel} ${enabledLabel}` - : copy.genericTitle, + actionLabel: suppliedLabel ? copy.namedAction(suppliedLabel) : copy.genericAction, + title: suppliedLabel ? copy.namedTitle(suppliedLabel) : copy.genericTitle, description: suppliedDescription ?? copy.genericDescription, label, countLabel: copy.count(n), diff --git a/packages/ui/src/tool-output-stream.ts b/packages/ui/src/tool-output-stream.ts index ec7d56e019..c5fb39d9ad 100644 --- a/packages/ui/src/tool-output-stream.ts +++ b/packages/ui/src/tool-output-stream.ts @@ -99,7 +99,7 @@ export interface ApplyToolOutputChunkOptions { maxChunks?: number; maxTotalChars?: number; maxChunkChars?: number; - locale?: UiLocale; + locale: UiLocale; } export interface ApplyToolOutputChunkResult { @@ -142,12 +142,12 @@ export interface ApplyToolOutputChunkResult { export function applyToolOutputChunk( prevChunks: ToolOutputChunk[] | undefined, rawChunk: ToolOutputChunk, - options: ApplyToolOutputChunkOptions = {}, + options: ApplyToolOutputChunkOptions, ): ApplyToolOutputChunkResult { const maxChunks = options.maxChunks ?? TOOL_STREAM_MAX_CHUNKS; const maxTotalChars = options.maxTotalChars ?? TOOL_STREAM_MAX_TOTAL_CHARS; const maxChunkChars = options.maxChunkChars ?? TOOL_STREAM_MAX_CHUNK_CHARS; - const truncatedChunkMarker = getSharedUiCopy(options.locale ?? 'zh-CN').stream.toolChunkTruncated; + const truncatedChunkMarker = getSharedUiCopy(options.locale).stream.toolChunkTruncated; const list = prevChunks ?? []; From d9fb14f894da318a050891fe4fe249b3baabdd93 Mon Sep 17 00:00:00 2001 From: NekoPunch Date: Fri, 4 Sep 2026 00:22:14 -0700 Subject: [PATCH 2/2] fix(i18n): localize health error details Generated-by: OpenCode --- .../main/__tests__/health-center-copy.test.ts | 18 ++++++++++++++---- .../renderer/locales/settings-health-copy.ts | 19 ++++++++++++++++--- .../settings/settings-pages.stories.tsx | 4 ++-- packages/core/src/__tests__/health.test.ts | 5 +---- packages/core/src/health.ts | 11 ++++------- 5 files changed, 37 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/main/__tests__/health-center-copy.test.ts b/apps/desktop/src/main/__tests__/health-center-copy.test.ts index 5bf1fd1284..018987fd3e 100644 --- a/apps/desktop/src/main/__tests__/health-center-copy.test.ts +++ b/apps/desktop/src/main/__tests__/health-center-copy.test.ts @@ -78,16 +78,26 @@ test('renders runtime probe details from structured params, not string parsing', const detail = { kind: 'runtime_probe_result', modelId: 'claude-sonnet-5', latencyMs: 812, errorClass: 'timeout' } as const; assert.equal( getHealthCenterCopy('zh-CN').signalDetail(signal({ detail })), - '模型=claude-sonnet-5 · 延迟=812ms · 错误类型=timeout', + '模型=claude-sonnet-5 · 延迟=812ms · 错误类型=请求超时', ); assert.equal( getHealthCenterCopy('en').signalDetail(signal({ detail })), - 'Model=claude-sonnet-5 · Latency=812ms · Error type=timeout', + 'Model=claude-sonnet-5 · Latency=812ms · Error type=Request timed out', + ); + + const unknown = { ...detail, errorClass: 'future_runtime_error' }; + assert.equal( + getHealthCenterCopy('zh-CN').signalDetail(signal({ detail: unknown })), + '模型=claude-sonnet-5 · 延迟=812ms · 错误类型=未知错误', + ); + assert.equal( + getHealthCenterCopy('en').signalDetail(signal({ detail: unknown })), + 'Model=claude-sonnet-5 · Latency=812ms · Error type=Unknown error', ); }); test('degrades capability reasons safely in both locales', () => { - const unknown = { kind: 'capability_reason', reason: 'Discord rejected the Bot Token.' } as const; + const unknown = { kind: 'capability_reason' } as const; assert.equal( getHealthCenterCopy('zh-CN').signalDetail(signal({ detail: unknown })), '状态详情请见对应设置页。', @@ -103,7 +113,7 @@ test('maps connection test error classes without exposing machine tokens', () => assert.equal(getHealthCenterCopy('zh-CN').signalDetail(signal({ detail: auth })), '鉴权失败'); assert.equal(getHealthCenterCopy('en').signalDetail(signal({ detail: auth })), 'Authentication failed'); - const unknown = { kind: 'last_test_message', text: 'future_error_class' } as const; + const unknown = { kind: 'last_test_message' } as const; assert.equal( getHealthCenterCopy('zh-CN').signalDetail(signal({ detail: unknown })), '连接测试状态暂时无法显示,请重新测试。', diff --git a/apps/desktop/src/renderer/locales/settings-health-copy.ts b/apps/desktop/src/renderer/locales/settings-health-copy.ts index 57947b6e11..e7f5b15564 100644 --- a/apps/desktop/src/renderer/locales/settings-health-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-health-copy.ts @@ -271,7 +271,7 @@ function signalDetailZh(detail: HealthSignalDetail | undefined): string | undefi return [ `模型=${detail.modelId}`, `延迟=${detail.latencyMs}ms`, - ...(detail.errorClass ? [`错误类型=${detail.errorClass}`] : []), + ...(detail.errorClass ? [`错误类型=${localizedRuntimeErrorClass(detail.errorClass, 'zh-CN')}`] : []), ].join(' · '); case 'capability_reason': return '状态详情请见对应设置页。'; @@ -297,7 +297,7 @@ function signalDetailZhTw(detail: HealthSignalDetail | undefined): string | unde return [ `模型=${detail.modelId}`, `延遲=${detail.latencyMs}ms`, - ...(detail.errorClass ? [`錯誤類型=${detail.errorClass}`] : []), + ...(detail.errorClass ? [`錯誤類型=${localizedRuntimeErrorClass(detail.errorClass, 'zh-TW')}`] : []), ].join(' · '); case 'capability_reason': return '狀態詳細資料請參閱對應的設定頁。'; @@ -323,7 +323,7 @@ function signalDetailEn(detail: HealthSignalDetail | undefined): string | undefi return [ `Model=${detail.modelId}`, `Latency=${detail.latencyMs}ms`, - ...(detail.errorClass ? [`Error type=${detail.errorClass}`] : []), + ...(detail.errorClass ? [`Error type=${localizedRuntimeErrorClass(detail.errorClass, 'en')}`] : []), ].join(' · '); case 'capability_reason': return 'See the corresponding settings page for details.'; @@ -333,3 +333,16 @@ function signalDetailEn(detail: HealthSignalDetail | undefined): string | undefi return 'The connection test status is temporarily unavailable. Test again.'; } } + +const unknownRuntimeErrorClass = { + 'zh-CN': '未知错误', + 'zh-TW': '未知錯誤', + en: 'Unknown error', +} satisfies UiCatalog; + +function localizedRuntimeErrorClass(errorClass: string, locale: UiLocale): string { + const messages: Readonly> = connectionTestErrorMessages[locale]; + const normalized = errorClass.toLowerCase(); + if (normalized === 'unknown') return unknownRuntimeErrorClass[locale]; + return messages[normalized] ?? unknownRuntimeErrorClass[locale]; +} diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index d7a94df48b..da8d303e15 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -609,7 +609,7 @@ const healthSignals: HealthSignal[] = [ source: 'connection_test', checkedAt: NOW - 3 * 60_000, message: 'needs_reauth', - detail: { kind: 'last_test_message', text: 'HTTP 401 invalid_api_key' }, + detail: { kind: 'last_test_message' }, blocksSend: true, }, { @@ -632,7 +632,7 @@ const healthSignals: HealthSignal[] = [ source: 'runtime_probe', checkedAt: NOW - 5 * 60_000, message: 'capability_degraded', - detail: { kind: 'capability_reason', reason: 'maka-cu 未在 3000ms 内完成握手;下一次探测会在功能被调用时自动触发。' }, + detail: { kind: 'capability_reason' }, relatedCapabilityId: 'computer_use', blocksCapability: true, }, diff --git a/packages/core/src/__tests__/health.test.ts b/packages/core/src/__tests__/health.test.ts index 3bbe88ac7a..9bb9bf8a19 100644 --- a/packages/core/src/__tests__/health.test.ts +++ b/packages/core/src/__tests__/health.test.ts @@ -55,10 +55,7 @@ describe('HealthSignal contract', () => { connection({ lastTestStatus: 'error', lastTestMessage: 'HTTP 502 upstream failure' }), 20, ); - assert.deepStrictEqual(legacy.detail, { - kind: 'last_test_message', - text: 'HTTP 502 upstream failure', - }); + assert.deepStrictEqual(legacy.detail, { kind: 'last_test_message' }); }); test('a missing default model warns only when the workspace has no default target', () => { diff --git a/packages/core/src/health.ts b/packages/core/src/health.ts index f7a43bce67..d36e1d5ab5 100644 --- a/packages/core/src/health.ts +++ b/packages/core/src/health.ts @@ -79,12 +79,9 @@ export type HealthSignalDetail = | { kind: 'not_default_source_hint' } | { kind: 'runtime_probe_layers_note' } | { kind: 'runtime_probe_result'; modelId: string; latencyMs: number; errorClass?: string } - // Raw machine token from the capability snapshot; the presenter maps the - // known vocabulary per locale and falls back to a generic hint. - | { kind: 'capability_reason'; reason: string } + | { kind: 'capability_reason' } | { kind: 'last_test_error_class'; errorClass: HealthConnectionTestErrorClass } - // Legacy connection-test diagnostic; the presenter uses locale-specific fallback copy. - | { kind: 'last_test_message'; text: string }; + | { kind: 'last_test_message' }; export interface HealthSignal { id: string; @@ -407,7 +404,7 @@ function capabilityDetail(capability: CapabilitySnapshot): HealthSignalDetail | capability.feature.reason ?? capability.configuration.reason )?.trim(); - return reason ? { kind: 'capability_reason', reason } : undefined; + return reason ? { kind: 'capability_reason' } : undefined; } function connectionLastTestDetail(message: string): HealthSignalDetail { @@ -421,7 +418,7 @@ function connectionLastTestDetail(message: string): HealthSignalDetail { case 'unknown': return { kind: 'last_test_error_class', errorClass: normalized }; default: - return { kind: 'last_test_message', text: message }; + return { kind: 'last_test_message' }; } }