From ae0e0cff46611dcdf26472b95f0455c0f8d4f108 Mon Sep 17 00:00:00 2001 From: NekoPunch Date: Sun, 6 Sep 2026 03:52:07 -0700 Subject: [PATCH] fix(desktop): localize expected errors Raw exception text no longer reaches the UI: expected failures carry machine-readable codes (with params where needed) that the renderer maps through locale catalogs, and unexpected failures show a localized fallback while redacted diagnostics go to the console. Plan controls and work-board actions carry those codes across IPC in a structured envelope, since Electron strips custom fields off thrown Errors. Generated-by: Claude Code --- apps/desktop/renderer-architecture.json | 52 ++- .../attachment-ingest-payload.test.ts | 6 +- .../attachment-ingest-resolve.test.ts | 22 +- .../expected-error-presentation.test.ts | 161 +++++++++ .../src/main/__tests__/plan-mode-copy.test.ts | 2 + .../__tests__/plan-mode-panel-pending.test.ts | 125 +++++++ .../runtime-host-guest-session-mounts.test.ts | 21 +- ...time-host-session-domains-ipc-main.test.ts | 148 ++++++-- .../__tests__/work-board-ipc-main.test.ts | 61 +++- .../main/__tests__/workhub-controller.test.ts | 104 +++++- .../__tests__/workhub-surface-flow.test.ts | 68 +++- apps/desktop/src/main/attachment-ingest.ts | 23 +- .../main/runtime-host-guest-session-mounts.ts | 22 +- .../runtime-host-session-domains-ipc-main.ts | 151 ++++++--- apps/desktop/src/main/work-board-ipc-main.ts | 15 +- .../src/preload/attachment-ingest-payload.ts | 12 +- apps/desktop/src/preload/bridge-contract.d.ts | 16 +- apps/desktop/src/preload/preload.ts | 40 +-- .../src/renderer/app-shell-command-actions.ts | 6 - apps/desktop/src/renderer/app-shell-copy.ts | 35 +- apps/desktop/src/renderer/app-shell.tsx | 2 +- .../contracts/operation-diagnostics.ts | 40 +++ .../features/session-collaboration/testing.ts | 1 + .../ui/session-collaboration-join-dialog.tsx | 40 ++- .../src/renderer/locales/plan-mode-copy.ts | 67 +++- .../locales/session-collaboration-copy.ts | 12 + .../src/renderer/locales/shell-copy.ts | 62 +++- .../renderer/locales/work-board-error-copy.ts | 64 ++++ .../src/renderer/locales/workhub-copy.ts | 316 ++++++++++++++++++ apps/desktop/src/renderer/plan-mode-panel.tsx | 71 ++-- .../remote-project-directory-dialog.tsx | 28 +- .../renderer/session-collaboration-dialog.tsx | 20 +- apps/desktop/src/renderer/use-work-board.ts | 34 +- .../desktop/src/renderer/work-board-panel.tsx | 28 +- .../src/renderer/workhub-controller.ts | 16 +- .../src/renderer/workhub-route-policy.ts | 12 +- apps/desktop/src/renderer/workhub-surface.tsx | 279 ++-------------- .../src/shared/session-collaboration.d.ts | 6 +- apps/desktop/src/shared/work-board-ipc.ts | 10 +- packages/core/src/attachments.ts | 14 + packages/core/src/redaction.ts | 18 + packages/core/src/web-search.ts | 2 +- .../host-operation-error-class.json | 8 + .../runtime-host/src/client/connection.ts | 12 +- .../runtime-host/src/protocol/operations.ts | 14 + packages/runtime-host/src/protocol/plan.ts | 20 +- .../src/__tests__/session-manager.test.ts | 10 +- .../__tests__/tool-runtime-settlement.test.ts | 64 +++- packages/runtime/src/session-manager.ts | 30 +- packages/runtime/src/web-search-tool.ts | 4 +- 50 files changed, 1787 insertions(+), 607 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/expected-error-presentation.test.ts create mode 100644 apps/desktop/src/main/__tests__/plan-mode-panel-pending.test.ts create mode 100644 apps/desktop/src/renderer/application/contracts/operation-diagnostics.ts create mode 100644 apps/desktop/src/renderer/locales/work-board-error-copy.ts create mode 100644 apps/desktop/src/renderer/locales/workhub-copy.ts create mode 100644 packages/runtime-host/protocol-compatible-changes/host-operation-error-class.json diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 85906cc9e0..ef524c5075 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -88,6 +88,8 @@ "src/renderer/locales/shell-copy.ts", "src/renderer/locales/shell-remaining-copy.ts", "src/renderer/locales/task-readiness-copy.ts", + "src/renderer/locales/work-board-error-copy.ts", + "src/renderer/locales/workhub-copy.ts", "src/renderer/main.tsx", "src/renderer/mcp-brand-contrast.ts", "src/renderer/mcp-brand-marks.tsx", @@ -392,7 +394,7 @@ "react": 1 }, "importSpecifiers": 9, - "nonTriviaTokens": 2307 + "nonTriviaTokens": 2283 }, "src/renderer/app-shell-context-compaction.ts": { "importDeclarations": 0, @@ -409,7 +411,7 @@ "nonTriviaTokens": 610 }, "src/renderer/app-shell-copy.ts": { - "importDeclarations": 1, + "importDeclarations": 0, "bridgePaths": {}, "environmentCapabilities": {}, "hookCalls": {}, @@ -417,11 +419,11 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "./locales/shell-copy.js": 1, - "@maka/core/redaction": 1 + "./application/contracts/operation-diagnostics.js": 1, + "./locales/shell-copy.js": 1 }, - "importSpecifiers": 1, - "nonTriviaTokens": 504 + "importSpecifiers": 0, + "nonTriviaTokens": 363 }, "src/renderer/app-shell-detail-panel.tsx": { "importDeclarations": 0, @@ -893,7 +895,7 @@ "react": 1 }, "importSpecifiers": 121, - "nonTriviaTokens": 14996 + "nonTriviaTokens": 14992 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, @@ -1608,7 +1610,9 @@ "lifecycleMethods": {}, "unresolvedDependencies": 0, "actionFactories": [], - "dependencyPaths": {} + "dependencyPaths": { + "@maka/core/ui-locale": 1 + } }, "src/renderer/locales/session-collaboration-copy.ts": { "bridgePaths": {}, @@ -1759,7 +1763,8 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "@maka/core/redaction": 1 + "@maka/core/redaction": 1, + "@maka/core/ui-locale": 1 } }, "src/renderer/locales/shell-remaining-copy.ts": { @@ -1780,6 +1785,26 @@ "actionFactories": [], "dependencyPaths": {} }, + "src/renderer/locales/work-board-error-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/ui-locale": 1 + } + }, + "src/renderer/locales/workhub-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, "src/renderer/mcp-brand-contrast.ts": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -2049,6 +2074,7 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { + "./application/contracts/operation-diagnostics.js": 1, "./locales/plan-mode-copy.js": 1, "@astryxdesign/core/Banner": 1, "@astryxdesign/core/Collapsible": 1, @@ -2085,6 +2111,7 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { + "./application/contracts/operation-diagnostics.js": 1, "./locales/shell-copy.js": 1, "@astryxdesign/core/Button": 1, "@astryxdesign/core/Dialog": 1, @@ -2150,6 +2177,7 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { + "./application/contracts/operation-diagnostics.js": 1, "./features/session-collaboration": 1, "./locales/session-collaboration-copy.js": 1, "@astryxdesign/core": 1, @@ -4517,6 +4545,7 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { + "./application/contracts/operation-diagnostics.js": 1, "@maka/core/work-board": 1, "react": 1 } @@ -4535,7 +4564,9 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { + "./application/contracts/operation-diagnostics.js": 1, "./locales/conversation-copy.js": 1, + "./locales/work-board-error-copy.js": 1, "./use-work-board.js": 1, "@astryxdesign/core": 1, "@astryxdesign/core/Button": 1, @@ -4553,6 +4584,7 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { + "./application/contracts/operation-diagnostics.js": 1, "./workhub-route-policy.js": 1 } }, @@ -4644,6 +4676,8 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { + "./application/contracts/operation-diagnostics.js": 1, + "./locales/workhub-copy.js": 2, "./workhub-coordination-port.js": 1, "./workhub-send-lease.js": 1, "@astryxdesign/core": 1, diff --git a/apps/desktop/src/main/__tests__/attachment-ingest-payload.test.ts b/apps/desktop/src/main/__tests__/attachment-ingest-payload.test.ts index 1c045ea75d..b1200b8bf7 100644 --- a/apps/desktop/src/main/__tests__/attachment-ingest-payload.test.ts +++ b/apps/desktop/src/main/__tests__/attachment-ingest-payload.test.ts @@ -24,7 +24,7 @@ import { encodeIngestItems } from '../../preload/attachment-ingest-payload.js'; describe('encodeIngestItems', () => { test('rejects more than 8 items without reading any file bytes', async () => { const items = Array.from({ length: 9 }, (_, i) => ({ approvalId: `a${i}`, name: `f${i}.txt` })); - await assert.rejects(encodeIngestItems(items as never), /8/); + await assert.rejects(encodeIngestItems(items as never), /attachment_ingest:count_exceeded/); }); test('rejects a File over 50MB without calling arrayBuffer', async () => { @@ -38,7 +38,7 @@ describe('encodeIngestItems', () => { return new ArrayBuffer(0); }, } as unknown as File; - await assert.rejects(encodeIngestItems([{ file: bigFile }]), /50/); + await assert.rejects(encodeIngestItems([{ file: bigFile }]), /attachment_ingest:size_exceeded/); assert.equal(arrayBufferCalls, 0, 'arrayBuffer must not be called for an oversized file'); }); @@ -67,7 +67,7 @@ describe('encodeIngestItems', () => { test('rejects a raw base64 item that is neither a File nor an approval token', async () => { await assert.rejects( encodeIngestItems([{ name: 'forged', base64: 'AAAA' }] as never), - /无效/, + /attachment_ingest:payload_invalid/, ); }); }); diff --git a/apps/desktop/src/main/__tests__/attachment-ingest-resolve.test.ts b/apps/desktop/src/main/__tests__/attachment-ingest-resolve.test.ts index 5128176131..4a4557d26f 100644 --- a/apps/desktop/src/main/__tests__/attachment-ingest-resolve.test.ts +++ b/apps/desktop/src/main/__tests__/attachment-ingest-resolve.test.ts @@ -44,7 +44,7 @@ describe('resolveIngestItems (pre-read validation)', () => { approvals, stat: async () => (statCalls++, { size: 1 }), }), - /最多/, + /attachment_ingest:count_limit/, ); assert.equal(statCalls, 0); }); @@ -60,7 +60,7 @@ describe('resolveIngestItems (pre-read validation)', () => { approvals, stat: async () => (statCalls++, { size: 1 }), }), - /过期|无效/, + /attachment_ingest:(source_expired|items_invalid)/, ); assert.equal(statCalls, 0); }); @@ -77,7 +77,7 @@ describe('resolveIngestItems (pre-read validation)', () => { approvals, stat: async () => (statCalls++, { size: 1 }), }), - /过期|无效/, + /attachment_ingest:(source_expired|items_invalid)/, ); assert.equal(statCalls, 0); }); @@ -95,7 +95,7 @@ describe('resolveIngestItems (pre-read validation)', () => { stat: async () => (statCalls++, { size: 200 }), maxBytes: 100, }), - /超出大小限制/, + /attachment_ingest:item_too_large/, ); assert.equal(statCalls, 1); }); @@ -120,7 +120,7 @@ describe('resolveIngestItems (pre-read validation)', () => { stat: async () => (statCalls++, { size: 1 }), maxBytes: 100, }), - /超出大小限制/, + /attachment_ingest:item_too_large/, ); assert.equal(statCalls, 0); assert.equal(decodeCalls, 0, 'must reject by base64 string length before Buffer.from'); @@ -148,7 +148,7 @@ describe('resolveIngestItems (pre-read validation)', () => { approvals, stat: async () => ({ size: 10 }), }), - /过期|无效/, + /attachment_ingest:(source_expired|items_invalid)/, ); }); @@ -184,7 +184,7 @@ describe('resolveIngestItems (pre-read validation)', () => { approvals, stat: async () => (statCalls++, { size: 10 }), }), - /无效/, + /attachment_ingest:items_invalid/, ); assert.notEqual( approvals.consumeApproval(1, issued.approvalId), @@ -207,7 +207,7 @@ describe('resolveIngestItems (pre-read validation)', () => { approvals, stat: async () => (statCalls++, { size: 10 }), }), - /重复/, + /attachment_ingest:duplicate_source/, ); assert.notEqual( approvals.consumeApproval(1, issued.approvalId), @@ -226,7 +226,7 @@ describe('resolveIngestItems (pre-read validation)', () => { approvals, stat: async () => ({ size: 1 }), }), - /无效/, + /attachment_ingest:items_invalid/, ); await assert.rejects( () => @@ -236,7 +236,7 @@ describe('resolveIngestItems (pre-read validation)', () => { approvals, stat: async () => ({ size: 1 }), }), - /无效/, + /attachment_ingest:items_invalid/, ); }); }); @@ -420,7 +420,7 @@ describe('resolveAttachmentRefs', () => { throw new Error('snapshot must not run'); }, }), - /超出大小限制/, + /attachment_ingest:item_too_large/, ); assert.equal(snapshots, 0); } finally { diff --git a/apps/desktop/src/main/__tests__/expected-error-presentation.test.ts b/apps/desktop/src/main/__tests__/expected-error-presentation.test.ts new file mode 100644 index 0000000000..3ddda8c187 --- /dev/null +++ b/apps/desktop/src/main/__tests__/expected-error-presentation.test.ts @@ -0,0 +1,161 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { getWorkBoardErrorCopy } from '../../renderer/locales/work-board-error-copy.js'; +import { workBoardActionErrorText } from '../../renderer/work-board-panel.js'; +import { ExpectedOperationError } from '../../renderer/application/contracts/operation-diagnostics.js'; +import { getSessionCollaborationCopy } from '../../renderer/locales/session-collaboration-copy.js'; +import { sessionCollaborationImportErrorMessage } from '../../renderer/features/session-collaboration/testing.js'; +import { messageReadErrorMessage } from '../../renderer/app-shell-copy.js'; +import { localizedShellErrorMessage } from '../../renderer/locales/shell-copy.js'; +import { getPlanModeCopy, planControlFailureCopy } from '../../renderer/locales/plan-mode-copy.js'; + +test('routes Work Board codes through the shared presenter per locale', (context) => { + context.mock.method(console, 'error', () => undefined); + for (const locale of ['zh-CN', 'zh-TW', 'en'] as const) { + const errorCopy = getWorkBoardErrorCopy(locale); + assert.equal( + workBoardActionErrorText( + new ExpectedOperationError('not_found'), + locale, + 'action-failed-fallback', + ), + errorCopy.not_found, + ); + } + // An unexpected failure keeps the caller fallback (and logs redacted + // diagnostics) instead of rendering the raw error. + assert.equal( + workBoardActionErrorText(new Error('raw internal detail'), 'zh-CN', '操作失败兜底'), + '操作失败兜底', + ); +}); + +test('maps attachment-ingest tokens per locale at the shared entry', () => { + const blocked = new Error("Error invoking remote method 'attachments': Error: attachment_ingest:count_limit"); + assert.equal(localizedShellErrorMessage(blocked, 'fallback', 'zh-CN'), '一次最多添加 8 个附件。'); + assert.equal( + localizedShellErrorMessage(blocked, 'fallback', 'en'), + 'At most 8 attachments per message.', + ); +}); + +test('the ingest token only matches at the message tail', (context) => { + context.mock.method(console, 'error', () => undefined); + const bare = 'attachment_ingest:count_limit'; + const wrapped = "Error invoking remote method 'sessions:send': Error: attachment_ingest:count_limit"; + assert.equal(localizedShellErrorMessage(new Error(bare), 'fallback', 'zh-CN'), '一次最多添加 8 个附件。'); + assert.equal(localizedShellErrorMessage(new Error(wrapped), 'fallback', 'en'), 'At most 8 attachments per message.'); + // Unrelated messages that merely contain the substring keep the fallback + // and take the unexpected-error diagnostics path. + const sneaky = 'Unable to open /tmp/attachment_ingest:count_limit/report.txt'; + assert.equal(localizedShellErrorMessage(new Error(sneaky), 'fallback', 'zh-CN'), 'fallback'); + assert.equal(localizedShellErrorMessage('path attachment_ingest:count_limit extra', 'fallback', 'en'), 'fallback'); +}); + +test('a classified shell failure renders its category without an unexpected diagnostic', (context) => { + const errors = context.mock.method(console, 'error', () => undefined); + assert.equal(localizedShellErrorMessage(new Error('request timeout'), 'fallback', 'zh-CN'), '请求超时'); + assert.equal(errors.mock.callCount(), 0); + assert.equal(localizedShellErrorMessage(new Error('boom'), 'fallback', 'zh-CN'), 'fallback'); + assert.equal(errors.mock.callCount(), 1); +}); + +test('maps plan control envelopes per locale at the panel', () => { + for (const locale of ['zh-CN', 'zh-TW', 'en'] as const) { + const copy = getPlanModeCopy(locale); + for (const code of ['session_busy', 'operation_conflict', 'not_found', 'persistence_failed', 'unauthorized'] as const) { + // The wire shape the panel actually receives: the main process returns + // the structured envelope; Electron would have stripped a thrown + // typed error's custom fields, which is why the envelope exists. + const failure = { + ok: false, + error: { code, message: 'provider detail' }, + } as const; + assert.equal( + planControlFailureCopy(failure.error, copy), + copy.controlFailure[code], + `${locale}: ${code}`, + ); + } + // Unknown codes keep the generic line (version-skew safety). + const unknownCode = { ok: false, error: { code: 'future_code', message: '' } } as const; + assert.equal( + planControlFailureCopy(unknownCode.error, copy), + copy.operationFailed, + ); + } +}); + + +test('unknown and inherited reason tokens retain the caller fallback', (context) => { + context.mock.method(console, 'error', () => undefined); + for (const locale of ['zh-CN', 'zh-TW', 'en'] as const) { + for (const code of ['future_code', 'constructor']) { + const token = `attachment_ingest:${code}`; + for (const error of [token, new Error(token)]) { + assert.equal(localizedShellErrorMessage(error, 'fallback', locale), 'fallback'); + } + } + } +}); + +test('routes structured collaboration failures through each locale catalog', () => { + const cases = [ + [{ kind: 'error', reason: 'invalid_code' } as const, 'invalidCode'], + [{ kind: 'error', reason: 'peer_path_unavailable' } as const, 'directPathUnavailable'], + [{ kind: 'error', reason: 'connection_failed' } as const, 'connectionFailed'], + ] as const; + for (const locale of ['zh-CN', 'zh-TW', 'en'] as const) { + const copy = getSessionCollaborationCopy(locale); + for (const [result, key] of cases) { + assert.equal(sessionCollaborationImportErrorMessage(copy, result), copy[key]); + } + assert.equal( + sessionCollaborationImportErrorMessage(copy, { + kind: 'error', + reason: 'mount_limit_reached', + params: { max: 12 }, + }), + copy.mountLimit(12), + ); + } +}); + +test('shell errors keep the generalized classifier over raw text', (context) => { + context.mock.method(console, 'error', () => undefined); + const raw = new Error('timeout 401 网络失败 MAKA_SESSION_READ_MESSAGES_ERROR: 后端中文'); + assert.equal( + messageReadErrorMessage(raw, 'en'), + 'Task content is temporarily unavailable. Try again later.', + ); + assert.equal(messageReadErrorMessage(raw, 'zh-CN'), '任务内容暂时无法读取,请稍后重试。'); + assert.equal( + localizedShellErrorMessage(raw, 'English fallback', 'en'), + 'Request timed out', + ); + assert.equal( + localizedShellErrorMessage(raw, '中文兜底', 'zh-CN'), + '请求超时', + ); + // Unmatched, unclassifiable failures keep the caller fallback. + assert.equal(localizedShellErrorMessage(new Error('no category here'), 'English fallback', 'en'), 'English fallback'); +}); diff --git a/apps/desktop/src/main/__tests__/plan-mode-copy.test.ts b/apps/desktop/src/main/__tests__/plan-mode-copy.test.ts index af8cc19329..1d6b5b050a 100644 --- a/apps/desktop/src/main/__tests__/plan-mode-copy.test.ts +++ b/apps/desktop/src/main/__tests__/plan-mode-copy.test.ts @@ -29,6 +29,8 @@ test('localizes Plan Mode chrome and abandon confirmation without rewriting plan assert.equal(en.proposal.statuses.approved, 'Approved'); assert.equal(zh.execution.stepCount(2, 3), '2/3 步'); assert.equal(en.execution.stepCount(1, 1), '1/1 step'); + assert.equal(zh.operationFailed, '计划操作失败,请稍后重试。'); + assert.equal(en.operationFailed, 'The plan action failed. Try again later.'); assert.deepEqual( { title: en.abandonConfirmation.title, diff --git a/apps/desktop/src/main/__tests__/plan-mode-panel-pending.test.ts b/apps/desktop/src/main/__tests__/plan-mode-panel-pending.test.ts new file mode 100644 index 0000000000..ef4d6f34c0 --- /dev/null +++ b/apps/desktop/src/main/__tests__/plan-mode-panel-pending.test.ts @@ -0,0 +1,125 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { deferred } from '@maka/core/test-only/async-primitives'; +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import type { PlanSessionState } from '@maka/core/plan'; +import type { SessionSummary } from '@maka/core/session'; +import type { PlanControlIpcResult } from '@maka/runtime-host/protocol'; +import { AstryxLocaleProvider, LocaleProvider, ToastProvider } from '@maka/ui'; +import { usePlanModeState, type PlanModeState } from '../../renderer/plan-mode-panel.js'; + +const originalGlobals = { + document: globalThis.document, + window: globalThis.window, + HTMLElement: globalThis.HTMLElement, + HTMLIFrameElement: globalThis.HTMLIFrameElement, + Event: globalThis.Event, + Node: globalThis.Node, + CSS: globalThis.CSS, + matchMedia: globalThis.matchMedia, + requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, + IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT, +}; + +let mountedRoot: Root | undefined; + +afterEach(async () => { + if (mountedRoot) await act(() => mountedRoot?.unmount()); + mountedRoot = undefined; + Object.assign(globalThis, originalGlobals); +}); + +test('plan controls stay pending until the bridge promise settles', async () => { + const planState = { storeVersion: 1 } as unknown as PlanSessionState; + const revision = deferred>(); + const { document, window } = parseHTML('
'); + const matchMedia = (media: string) => ({ + matches: false, + media, + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent: () => false, + }); + Object.assign(window, { + matchMedia, + scrollTo() {}, + maka: { + sessions: { + getPlanState: async () => planState, + subscribeEvents: () => () => {}, + subscribePlanChanges: () => () => {}, + requestPlanRevision: () => revision.promise, + }, + }, + }); + Object.assign(globalThis, { + document, + window, + matchMedia, + HTMLElement: window.HTMLElement, + HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {}, + Event: window.Event, + Node: window.Node, + CSS: { escape: (value: string) => value }, + requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(callback, 0), + cancelAnimationFrame: (handle: number) => clearTimeout(handle), + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + mountedRoot = root; + + let controller: PlanModeState | undefined; + function Harness() { + controller = usePlanModeState({ id: 'session-1' } as SessionSummary); + return null; + } + await act(async () => { + root.render(createElement(LocaleProvider, { + locale: 'en', + children: createElement(AstryxLocaleProvider, { + children: createElement(ToastProvider, { children: createElement(Harness) }), + }), + })); + }); + assert.equal(controller?.pending, false); + + let inFlight!: Promise; + await act(async () => { + inFlight = controller!.requestRevision('proposal-1'); + }); + assert.equal(controller?.pending, true, 'pending must hold while the bridge call is unsettled'); + + revision.resolve({ ok: true, value: planState }); + await act(async () => { + await inFlight; + }); + assert.equal(controller?.pending, false); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-guest-session-mounts.test.ts b/apps/desktop/src/main/__tests__/runtime-host-guest-session-mounts.test.ts index c7a55ff1b4..a319a12ac9 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-guest-session-mounts.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-guest-session-mounts.test.ts @@ -331,7 +331,7 @@ test('removes failed activation desire instead of creating recoverable profile s }); const result = await mounts.importInvitation(invitation('guest-two'), false, 'import-two'); - assert.deepEqual(result.kind === 'error' ? result.reason : result.kind, 'peer_path_unavailable'); + assert.deepEqual(result, { kind: 'error', reason: 'peer_path_unavailable' }); assert.deepEqual(await store.read(), []); assert.equal(unmounted.length, 1); }); @@ -740,6 +740,25 @@ test('does not lose a catalog invalidation that races Guest activation', async ( await mounts.close(); }); +test('logs unexpected activation failures without returning Host details', async (context) => { + const diagnostics: string[] = []; + context.mock.method(console, 'error', (...values: unknown[]) => { + diagnostics.push(values.map(String).join(' ')); + }); + const mounts = service(memoryStore(), { + mount: async () => { + throw new Error('Authorization: Bearer very-secret-token'); + }, + }); + + const result = await mounts.importInvitation(invitation('guest-failed'), false, 'import-failed'); + + assert.deepEqual(result, { kind: 'error', reason: 'connection_failed' }); + assert.equal(diagnostics.length, 1); + assert.match(diagnostics[0]!, /session-collaboration.*import failed/u); + assert.doesNotMatch(diagnostics[0]!, /very-secret-token/u); +}); + test('settles admitted finalization before committing unmount desire', async () => { const store = memoryStore(); let started!: () => void; diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts index 0188653324..c85915104c 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts @@ -22,7 +22,7 @@ import test from 'node:test'; import type { IpcMain } from 'electron'; import { projectDeepResearchClientProgress } from '@maka/core/deep-research-client-progress'; import { type DeepResearchRun } from '@maka/core/deep-research-run'; -import { type PlanSessionState } from '@maka/core/plan'; +import { emptyPlanSessionState, type PlanSessionState } from '@maka/core/plan'; import { type ShellRunUpdate } from '@maka/core/events'; import { encodeDeepResearchSnapshot, @@ -885,35 +885,32 @@ test('adapts Plan controls and starts approved execution through one Host comman ipc, ); - assert.deepEqual(await ipc.invoke('plan-mode:requestRevision', 'session-1', 'proposal-1'), state); + assert.deepEqual( + await ipc.invoke('plan-mode:requestRevision', 'session-1', 'proposal-1'), + { ok: true, value: state }, + ); const approvalInput = { proposalId: 'proposal-1', expectedRevision: 2, expectedStoreVersion: 3, turnId: 'approval-turn', }; - assert.deepEqual( - await ipc.invoke('plan-mode:approve', 'session-1', approvalInput), - { turnId: 'approval-turn', executionId: 'execution-1' }, - ); - assert.deepEqual( - await ipc.invoke('plan-mode:approve', 'session-1', approvalInput), - { turnId: 'approval-turn', executionId: 'execution-1' }, - ); - assert.deepEqual( - await ipc.invoke('plan-mode:resume', 'session-1', 'execution-1', 'resume-turn'), - { - turnId: 'resume-turn', - executionId: 'execution-1', - }, - ); - assert.deepEqual( - await ipc.invoke('plan-mode:resume', 'session-1', 'execution-1', 'resume-turn'), - { - turnId: 'resume-turn', - executionId: 'execution-1', - }, - ); + assert.deepEqual(await ipc.invoke('plan-mode:approve', 'session-1', approvalInput), { + ok: true, + value: { turnId: 'approval-turn', executionId: 'execution-1' }, + }); + assert.deepEqual(await ipc.invoke('plan-mode:approve', 'session-1', approvalInput), { + ok: true, + value: { turnId: 'approval-turn', executionId: 'execution-1' }, + }); + assert.deepEqual(await ipc.invoke('plan-mode:resume', 'session-1', 'execution-1', 'resume-turn'), { + ok: true, + value: { turnId: 'resume-turn', executionId: 'execution-1' }, + }); + assert.deepEqual(await ipc.invoke('plan-mode:resume', 'session-1', 'execution-1', 'resume-turn'), { + ok: true, + value: { turnId: 'resume-turn', executionId: 'execution-1' }, + }); assert.deepEqual(calls, [ { kind: 'control', @@ -1405,3 +1402,106 @@ function registerDomainsIpc( ipcMain, ); } + +test('plan control channels rethrow failures outside the expected plan-control set', async () => { + const ipc = ipcHarness(); + const boom = new Error('socket exploded'); + registerDomainsIpc({ + client: domainClient({ + getPlanState: async () => emptyPlanSessionState('session-1'), + controlPlan: async () => { + throw boom; + }, + startPlanTurn: async () => { + throw boom; + }, + }), + emitModeChanged: () => {}, + newId: () => 'fixed-id', + }, ipc); + await assert.rejects( + () => ipc.invoke('plan-mode:requestRevision', 'session-1', 'proposal-1'), + (error: unknown) => error === boom, + ); + await assert.rejects( + () => ipc.invoke('plan-mode:abandon', 'session-1', 'proposal-1'), + (error: unknown) => error === boom, + ); + await assert.rejects( + () => ipc.invoke('plan-mode:approve', 'session-1', { + proposalId: 'proposal-1', + expectedRevision: 2, + expectedStoreVersion: 3, + turnId: 'turn-1', + }), + (error: unknown) => error === boom, + ); + await assert.rejects( + () => ipc.invoke('plan-mode:resume', 'session-1', 'execution-1', 'turn-1'), + (error: unknown) => error === boom, + ); + await assert.rejects( + () => ipc.invoke('plan-mode:abandonExecution', 'session-1', 'execution-1'), + (error: unknown) => error === boom, + ); +}); + +test('plan control channels return the Host error code across the IPC boundary', async () => { + const cases = [ + { + channel: 'plan-mode:requestRevision', + args: ['session-1', 'proposal-1'], + operation: 'plan.control', + code: 'session_busy', + }, + { + channel: 'plan-mode:abandon', + args: ['session-1', 'proposal-1'], + operation: 'plan.control', + code: 'operation_conflict', + }, + { + channel: 'plan-mode:approve', + args: [ + 'session-1', + { proposalId: 'proposal-1', expectedRevision: 2, expectedStoreVersion: 3, turnId: 'turn-1' }, + ], + operation: 'plan.turn.start', + code: 'operation_conflict', + }, + { + channel: 'plan-mode:resume', + args: ['session-1', 'execution-1', 'turn-1'], + operation: 'plan.turn.start', + code: 'session_busy', + }, + { + channel: 'plan-mode:abandonExecution', + args: ['session-1', 'execution-1'], + operation: 'plan.control', + code: 'persistence_failed', + }, + ] as const; + for (const scenario of cases) { + const ipc = ipcHarness(); + const changed: string[] = []; + const reject = async () => { + throw new RuntimeHostOperationError(scenario.operation, scenario.code, 'Host refused the plan control'); + }; + registerDomainsIpc({ + client: domainClient({ + getPlanState: async () => emptyPlanSessionState('session-1'), + controlPlan: reject, + startPlanTurn: reject, + }), + emitModeChanged: (sessionId) => changed.push(sessionId), + newId: () => 'fixed-id', + }, ipc); + assert.deepEqual( + await ipc.invoke(scenario.channel, ...scenario.args), + { ok: false, error: { code: scenario.code, message: 'Host refused the plan control' } }, + `${scenario.channel} must carry the Host error code`, + ); + assert.deepEqual(changed, [], `${scenario.channel} must not report a mode change`); + } +}); diff --git a/apps/desktop/src/main/__tests__/work-board-ipc-main.test.ts b/apps/desktop/src/main/__tests__/work-board-ipc-main.test.ts index aaabba2f51..3ad8a30af6 100644 --- a/apps/desktop/src/main/__tests__/work-board-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/work-board-ipc-main.test.ts @@ -23,6 +23,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, test } from 'node:test'; import type { IpcMain } from 'electron'; +import { createWorkBoardStore, WorkBoardStoreError } from '@maka/storage/work-board-store'; import { registerWorkBoardIpc, type WorkBoardChangedEvent, @@ -88,6 +89,54 @@ async function withTempRoot(run: (root: string) => Promise): Promise } describe('Work Board IPC', () => { + for (const [code, detail] of [ + ['corrupt_record', 'Work Board item item-1 has invalid record_json'], + ['corrupt_record', 'Work Board item item-2 failed contract validation'], + ['corrupt_record', 'Work Board item item-3 has indexed columns that disagree with record_json'], + ['unknown', 'Database read failed'], + ['invalid_input', 'Work Board item id is invalid'], + ['not_found', 'Work Board item item-4 was not found'], + ['operation_conflict', 'Work Board item revision changed'], + ['must_archive_first', 'Only archived Work Board items can be deleted'], + ] as const) { + test(`returns only ${code} and preserves diagnostic policy: ${detail}`, async (t) => { + await withTempRoot(async (root) => { + const ipc = createFakeIpcMain(); + const window = createFakeWindowController(); + const store = createWorkBoardStore(root); + const message = `${detail}; token=board-diagnostic-secret`; + t.mock.method(store, 'list', async () => { + throw code === 'unknown' ? new Error(message) : new WorkBoardStoreError(code, message); + }); + const logger = t.mock.method(console, 'error', () => {}); + const registration = registerWorkBoardIpc({ + ipcMain: ipc as unknown as Pick, + workspaceRoot: root, + mainWindowController: window, + store, + }); + try { + const response = await ipc.invoke>('workBoard:list', {}); + assert.deepEqual(response, { ok: false, error: { code } }); + assert.deepEqual(window.events, []); + if (code === 'corrupt_record' || code === 'unknown') { + assert.equal(logger.mock.callCount(), 1); + const [prefix, diagnostic] = logger.mock.calls[0]!.arguments; + assert.equal(prefix, '[work-board] operation failed:'); + assert.equal(typeof diagnostic, 'string'); + assert.ok(String(diagnostic).includes(detail)); + assert.match(String(diagnostic), /token=\[redacted\]/); + assert.doesNotMatch(JSON.stringify(logger.mock.calls), /board-diagnostic-secret/); + } else { + assert.equal(logger.mock.callCount(), 0); + } + } finally { + registration.close(); + } + }); + }); + } + test('creates and lists items and emits change signals', async () => { await withTempRoot(async (root) => { const ipc = createFakeIpcMain(); @@ -164,8 +213,10 @@ describe('Work Board IPC', () => { { title: 'stale write' }, { expectedRevision: 1 }, ); - assert.equal(staleRename.ok, false); - if (!staleRename.ok) assert.equal(staleRename.code, 'operation_conflict'); + assert.deepEqual(staleRename, { + ok: false, + error: { code: 'operation_conflict' }, + }); const removedBeforeArchive = await ipc.invoke>( 'workBoard:remove', @@ -173,7 +224,7 @@ describe('Work Board IPC', () => { ); assert.equal(removedBeforeArchive.ok, false); if (!removedBeforeArchive.ok) { - assert.equal(removedBeforeArchive.code, 'must_archive_first'); + assert.equal(removedBeforeArchive.error.code, 'must_archive_first'); } const archived = await ipc.invoke< @@ -194,14 +245,14 @@ describe('Work Board IPC', () => { { titel: 'x' }, ); assert.equal(invalidPatch.ok, false); - if (!invalidPatch.ok) assert.equal(invalidPatch.code, 'invalid_input'); + if (!invalidPatch.ok) assert.equal(invalidPatch.error.code, 'invalid_input'); const invalidCreate = await ipc.invoke>( 'workBoard:create', { ...itemInput(), notes: null }, ); assert.equal(invalidCreate.ok, false); - if (!invalidCreate.ok) assert.equal(invalidCreate.code, 'invalid_input'); + if (!invalidCreate.ok) assert.equal(invalidCreate.error.code, 'invalid_input'); await ipc.invoke('workBoard:archive', id); const removed = await ipc.invoke>('workBoard:remove', id); diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index 8c9823f6b8..995e457f60 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -31,9 +31,11 @@ import { } from '../../renderer/workhub-controller.js'; import { createWorkHubRoutePolicy, - workHubNewSessionName, + workHubNewSessionName as workHubNewSessionNameForLocale, } from '../../renderer/workhub-route-policy.js'; +const workHubNewSessionName = (text: string) => workHubNewSessionNameForLocale(text, '新工作'); + const appShellUrl = [ new URL('../../renderer/app-shell.tsx', import.meta.url), new URL('../../../src/renderer/app-shell.tsx', import.meta.url), @@ -361,7 +363,7 @@ test('direct stop bypasses routing candidates and preserves a not_owned delegati }); const handle = await controller.openConversation(() => undefined, () => undefined); - const result = await controller.submit({ requestId: 'stop-1', text: 'Stop Payments' }); + const result = await controller.submit({ newSessionFallbackTitle: 'New work', requestId: 'stop-1', text: 'Stop Payments' }); assert.deepEqual(result, { kind: 'stop', strategyId: WORKHUB_ROUTING_STRATEGY_ID, @@ -384,7 +386,7 @@ test('direct stop bypasses routing candidates and preserves a not_owned delegati }]); assert.equal(candidateReads, 0); - const retry = await controller.submit({ requestId: 'stop-2', text: 'Stop Payments' }); + const retry = await controller.submit({ newSessionFallbackTitle: 'New work', requestId: 'stop-2', text: 'Stop Payments' }); assert.equal(retry.kind, 'stop'); assert.equal(actions.length, 2); await handle.close(); @@ -405,7 +407,7 @@ test('an anaphoric stop asks for a fresh named imperative without offering a rou }, }); const handle = await controller.openConversation(() => undefined, () => undefined); - assert.deepEqual(await controller.submit({ requestId: 'stop-it', text: 'Stop it' }), { + assert.deepEqual(await controller.submit({ newSessionFallbackTitle: 'New work', requestId: 'stop-it', text: 'Stop it' }), { kind: 'clarification', strategyId: WORKHUB_ROUTING_STRATEGY_ID, requestId: 'stop-it', @@ -444,7 +446,7 @@ test('a named resume submits and reports what the Host did', async () => { }); const handle = await controller.openConversation(() => undefined, () => undefined); - const result = await controller.submit({ requestId: 'resume-1', text: 'Resume Payments' }); + const result = await controller.submit({ newSessionFallbackTitle: 'New work', requestId: 'resume-1', text: 'Resume Payments' }); assert.deepEqual(result, { kind: 'resume', @@ -478,7 +480,7 @@ test('an anaphoric resume asks for a named work item', async () => { }); const handle = await controller.openConversation(() => undefined, () => undefined); - assert.deepEqual(await controller.submit({ requestId: 'resume-it', text: 'Resume it' }), { + assert.deepEqual(await controller.submit({ newSessionFallbackTitle: 'New work', requestId: 'resume-it', text: 'Resume it' }), { kind: 'clarification', strategyId: WORKHUB_ROUTING_STRATEGY_ID, requestId: 'resume-it', @@ -513,7 +515,7 @@ test('a resume the Host will not admit becomes its clarification', async () => { }); const handle = await controller.openConversation(() => undefined, () => undefined); - assert.deepEqual(await controller.submit({ requestId: 'resume-2', text: 'Resume Payments' }), { + assert.deepEqual(await controller.submit({ newSessionFallbackTitle: 'New work', requestId: 'resume-2', text: 'Resume Payments' }), { kind: 'clarification', strategyId: WORKHUB_ROUTING_STRATEGY_ID, requestId: 'resume-2', @@ -549,7 +551,7 @@ test('a resume identity conflict is not mislabeled as a missing target', async ( const handle = await controller.openConversation(() => undefined, () => undefined); await assert.rejects( - controller.submit({ requestId: 'resume-conflict', text: 'Resume Payments' }), + controller.submit({ newSessionFallbackTitle: 'New work', requestId: 'resume-conflict', text: 'Resume Payments' }), (error) => error === conflict, ); await handle.close(); @@ -578,7 +580,7 @@ test('a Runtime Host without safe-boundary resume explains why it cannot resume' }); const handle = await controller.openConversation(() => undefined, () => undefined); - assert.deepEqual(await controller.submit({ requestId: 'resume-disabled', text: 'Resume Payments' }), { + assert.deepEqual(await controller.submit({ newSessionFallbackTitle: 'New work', requestId: 'resume-disabled', text: 'Resume Payments' }), { kind: 'clarification', strategyId: WORKHUB_ROUTING_STRATEGY_ID, requestId: 'resume-disabled', @@ -609,7 +611,7 @@ test('a recovering Runtime Host tells the user to retry resume', async () => { }); const handle = await controller.openConversation(() => undefined, () => undefined); - const result = await controller.submit({ requestId: 'resume-recovering', text: 'Resume Payments' }); + const result = await controller.submit({ newSessionFallbackTitle: 'New work', requestId: 'resume-recovering', text: 'Resume Payments' }); assert.equal(result.kind, 'clarification'); if (result.kind === 'clarification') assert.equal(result.reason, 'resume_host_recovering'); await handle.close(); @@ -641,7 +643,7 @@ test('a named stop reports the Gate refusal instead of judging the target itself }); const handle = await controller.openConversation(() => undefined, () => undefined); - assert.deepEqual(await controller.submit({ requestId: 'stop-payments', text: 'Stop Payments' }), { + assert.deepEqual(await controller.submit({ newSessionFallbackTitle: 'New work', requestId: 'stop-payments', text: 'Stop Payments' }), { kind: 'clarification', strategyId: WORKHUB_ROUTING_STRATEGY_ID, requestId: 'stop-payments', @@ -672,7 +674,7 @@ test('a stop that fails for any other reason is a fault, not a clarification', a const handle = await controller.openConversation(() => undefined, () => undefined); await assert.rejects( - () => controller.submit({ requestId: 'stop-payments', text: 'Stop Payments' }), + () => controller.submit({ newSessionFallbackTitle: 'New work', requestId: 'stop-payments', text: 'Stop Payments' }), /WorkHub stop state is unavailable/, ); await handle.close(); @@ -719,7 +721,7 @@ test('stop-shaped ordinary work routes normally instead of looping on clarificat }); const handle = await controller.openConversation(() => undefined, () => undefined); - const result = await controller.submit({ requestId: `work-${sessionName}`, text }); + const result = await controller.submit({ newSessionFallbackTitle: 'New work', requestId: `work-${sessionName}`, text }); assert.equal(result.kind, 'submitted', text); assert.deepEqual( actions.map((action) => action.proposal.disposition), @@ -827,6 +829,7 @@ test('archived Sessions stay inspectable but are excluded from routing targets', const projection = await controller.read(); const result = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'archived-target', text: '支付回调幂等性现在是什么状态?', }); @@ -847,6 +850,7 @@ test('submit sends an explicitly targeted request to that Session', async () => const controller = createWorkHubController({ sessions }); const result = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-1', text: '补充重复投递测试', explicitTarget: { sessionId: 'payment' }, @@ -878,6 +882,7 @@ test('submit routes a unique complete Session name without asking', async () => const controller = createWorkHubController({ sessions }); const result = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-exact', text: '在支付回调幂等性里补充重复投递测试', }); @@ -905,6 +910,7 @@ test('a unique longer Session name outranks a generic contained Session name', a }; const result = await createWorkHubController({ sessions }).submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-layout', text: '优化WorkHub移动端消息布局:补充横屏注意点。', }); @@ -929,6 +935,7 @@ test('a short Latin Session name does not match inside another word', async () = }; const result = await createWorkHubController({ sessions }).submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-parser', text: '修复 repair parser 的错误', }); @@ -955,6 +962,7 @@ test('a one-character Latin discriminator prevents routing to a different Sessio }; const result = await createWorkHubController({ sessions }).submit({ + newSessionFallbackTitle: '新工作', requestId: `request-${requestedName}`, text: `请处理 ${requestedName} 的问题`, }); @@ -987,6 +995,7 @@ test('submit asks the user when weak relevance matches more than one Session', a const controller = createWorkHubController({ sessions }); const result = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-ambiguous', text: '继续处理重复问题', }); @@ -1038,12 +1047,14 @@ test('submit keeps origin prompts as stable evidence after latest results change sessions.submit = async () => ({ turnId: 'turn-focus-login' }); const controller = createWorkHubController({ sessions }); await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-focus-login', text: '先看登录', explicitTarget: { sessionId: 'login' }, }); const result = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-origin-ambiguity', text: '继续处理重复问题', }); @@ -1074,7 +1085,7 @@ test('submit creates a new executable topic instead of following one weak old cl const controller = createWorkHubController({ sessions }); const text = '检查支付回调重复投递时的幂等性,先只分析风险和测试点,不修改文件。'; - const result = await controller.submit({ requestId: 'request-payment-new', text }); + const result = await controller.submit({ newSessionFallbackTitle: '新工作', requestId: 'request-payment-new', text }); assert.equal(result.kind, 'submitted'); assert.deepEqual(result.kind === 'submitted' ? result.target : undefined, { @@ -1104,7 +1115,7 @@ test('submit does not treat a project name as strong topic evidence', async () = const controller = createWorkHubController({ sessions }); const text = '优化 WorkHub 在移动端窄屏下的消息布局,先给设计建议,不修改文件。'; - const result = await controller.submit({ requestId: 'request-layout-new', text }); + const result = await controller.submit({ newSessionFallbackTitle: '新工作', requestId: 'request-layout-new', text }); assert.equal(result.kind, 'submitted'); assert.deepEqual(result.kind === 'submitted' ? result.target : undefined, { @@ -1126,12 +1137,14 @@ test('submit follows an unambiguous reference to the most recent Work', async () }; const controller = createWorkHubController({ sessions }); await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-focus', text: '先处理支付', explicitTarget: { sessionId: 'payment' }, }); const result = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-pronoun', text: '继续它', }); @@ -1162,10 +1175,12 @@ test('read seeds current and previous focus from pre-existing ordinary Sessions' await controller.read(); const current = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-current-seed', text: '继续这个工作', }); const previous = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-previous-seed', text: '回到上一个工作', }); @@ -1193,6 +1208,7 @@ test('read prefers the Session active when WorkHub opens over raw recency', asyn await controller.read({ focus: { sessionId: 'login' } }); const result = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-active-seed', text: '继续这个工作', }); @@ -1236,6 +1252,7 @@ test('a stale opening read cannot overwrite a newer WorkHub focus', async () => await older; sessions.list = async () => facts; const result = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-after-stale-read', text: '继续这个工作', }); @@ -1261,6 +1278,7 @@ test('an unavailable opening focus falls back to recent routable Sessions', asyn await controller.read({ focus: { sessionId: 'archived' } }); const result = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-fallback-focus', text: '继续这个工作', }); @@ -1290,6 +1308,7 @@ test('focus falls back when the current Session is archived after WorkHub opens' : entry); const result = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-after-current-archive', text: '继续这个工作', }); @@ -1321,6 +1340,7 @@ test('resetVisitContext discards focus from a previous WorkHub mount', async () await controller.read(); const result = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-after-remount', text: '继续这个工作', }); @@ -1350,6 +1370,7 @@ test('an in-flight submit cannot restore visit focus after WorkHub unmounts', as const controller = createWorkHubController({ sessions }); await controller.read({ focus: { sessionId: 'login' } }); const inFlight = controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-before-unmount', text: '继续这个工作', }); @@ -1365,6 +1386,7 @@ test('an in-flight submit cannot restore visit focus after WorkHub unmounts', as }; await controller.read(); const result = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-after-in-flight', text: '继续这个工作', }); @@ -1406,6 +1428,7 @@ test('an old submit resolves against the visit focus captured before an await', }; const oldSubmission = controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-old-visit', text: '继续这个工作', }); @@ -1439,12 +1462,14 @@ test('submit routes strong core evidence instead of reusing recent focus', async }; const controller = createWorkHubController({ sessions }); await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-login-focus', text: '先看登录', explicitTarget: { sessionId: 'login' }, }); const result = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-topic-shift', text: '继续处理支付回调重复投递', }); @@ -1479,6 +1504,7 @@ test('submit routes unique strong core evidence without asking', async () => { const controller = createWorkHubController({ sessions }); const result = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-core', text: '刷新令牌过期时,重复登录的观测日志应该记录哪些字段?', }); @@ -1507,7 +1533,7 @@ test('submit ignores shared boilerplate when an executable request names a new t const controller = createWorkHubController({ sessions }); const text = '请创建新任务,检查支付回调重复投递;先只分析风险和测试点,不修改文件。'; - const result = await controller.submit({ requestId: 'request-new-topic', text }); + const result = await controller.submit({ newSessionFallbackTitle: '新工作', requestId: 'request-new-topic', text }); assert.equal(result.kind, 'submitted'); if (result.kind !== 'submitted') return; @@ -1525,6 +1551,7 @@ test('submit keeps a foreign two-character clue behind clarification', async () const controller = createWorkHubController({ sessions }); const result = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-weak', text: '继续登录', }); @@ -1546,6 +1573,7 @@ test('submit treats explicit user uncertainty as clarification instead of a new const controller = createWorkHubController({ sessions }); const result = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-uncertain', text: '继续处理稳定性问题,但我不确定具体是哪一个。', }); @@ -1569,6 +1597,7 @@ test('English target uncertainty uses clarification as the routing safety valve' }; const result = await createWorkHubController({ sessions }).submit({ + newSessionFallbackTitle: '新工作', requestId: 'english-uncertainty', text: "I'm not sure which one this belongs to; continue the cleanup.", }); @@ -1596,6 +1625,7 @@ test('English routing matches whole words instead of substrings in another ident }; const result = await createWorkHubController({ sessions }).submit({ + newSessionFallbackTitle: '新工作', requestId: 'english-word-boundary', text: 'check the file parser', }); @@ -1624,6 +1654,7 @@ test('English core evidence requires a distinctive word or multiple whole-word m }; const result = await createWorkHubController({ sessions }).submit({ + newSessionFallbackTitle: '新工作', requestId: 'english-core-evidence', text: 'fix the parser tokenizer crash', }); @@ -1648,6 +1679,7 @@ test('waiting Session rejects a second root request without calling submit', asy const controller = createWorkHubController({ sessions }); const result = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-waiting', text: '排查令牌过期重复登录问题:补充一条等待状态下的新请求。', }); @@ -1674,17 +1706,20 @@ test('submit returns to the previous focused Session', async () => { }; const controller = createWorkHubController({ sessions }); await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-login', text: '先看登录', explicitTarget: { sessionId: 'login' }, }); await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-payment', text: '再看支付', explicitTarget: { sessionId: 'payment' }, }); const result = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-previous', text: '回到上一个工作', }); @@ -1712,12 +1747,14 @@ test('submit lets strong foreign core evidence override a vague focus word', asy }; const controller = createWorkHubController({ sessions }); await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-payment-focus', text: '先看支付', explicitTarget: { sessionId: 'payment' }, }); const result = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-foreign-core', text: '继续处理刷新令牌过期', }); @@ -1755,6 +1792,7 @@ test('submit keeps unmatched non-executable conversation in WorkHub', async () = }); const result = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-discussion', text: '你觉得统一入口最重要的价值是什么?', }); @@ -1812,6 +1850,7 @@ test('production submission delegates only through the Runtime-owned candidate r }); const result = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'delegate-action', text: '继续支付工作', explicitTarget: { sessionId: 'payment' }, @@ -1866,6 +1905,7 @@ test('production retry reaches durable Action Gate replay while target is waitin }); const result = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'summary-recovery-action', text: '继续支付工作', explicitTarget: { sessionId: 'payment' }, @@ -1925,6 +1965,7 @@ test('production sends an explicit correction as a linked replacement', async () }); const result = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'linked-correction', text: 'No, use target instead', explicitTarget: { sessionId: 'target' }, @@ -2064,11 +2105,13 @@ test('production natural-language corrections retain the prior delegation link', }); await controller.read(); await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'production-wrong-payment', text: '继续这个工作,补充验收项', }); const corrected = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'production-natural-correction', text: '不是这个,换成登录稳定性,补充刷新令牌失败判定', }); @@ -2076,7 +2119,7 @@ test('production natural-language corrections retain the prior delegation link', const [creationRequestId, creationText] = PRODUCTION_CORRECTION_CREATION_CASES[0]; assert.equal( - (await controller.submit({ requestId: creationRequestId, text: creationText })).kind, + (await controller.submit({ newSessionFallbackTitle: '新工作', requestId: creationRequestId, text: creationText })).kind, 'submitted', ); @@ -2124,7 +2167,7 @@ test('production correction-shaped creation stays create_new without an existing }); for (const [requestId, text] of PRODUCTION_CORRECTION_CREATION_CASES) { - const result = await controller.submit({ requestId: `without-focus-${requestId}`, text }); + const result = await controller.submit({ newSessionFallbackTitle: '新工作', requestId: `without-focus-${requestId}`, text }); assert.equal(result.kind, 'submitted'); } @@ -2198,6 +2241,7 @@ test('production creation leaves Session identity and workspace authority to mai }); const result = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'create-action', text: '请创建新任务,检查支付回调重复投递。', }); @@ -2226,6 +2270,7 @@ test('submit treats a design question containing an action word as discussion', const controller = createWorkHubController({ sessions }); const result = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-design-question', text: '我们应该怎么实现统一入口?', }); @@ -2244,6 +2289,7 @@ test('an executable English request may contain what without becoming discussion sessions.submit = async () => ({ turnId: 'turn-parser-fix' }); const result = await createWorkHubController({ sessions }).submit({ + newSessionFallbackTitle: '新工作', requestId: 'english-what-object', text: 'fix what is broken in the parser', }); @@ -2268,6 +2314,7 @@ test('submit creates an ordinary Session for a clear unmatched executable goal', const controller = createWorkHubController({ sessions }); const result = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-new-work', text: '实现导出发票 PDF 功能', }); @@ -2299,6 +2346,7 @@ test('explicit new-Session intent outranks generic evidence from existing work', sessions.submit = async () => ({ turnId: 'turn-new-session' }); const result = await createWorkHubController({ sessions }).submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-explicit-new', text: '创建一个全新的普通 Session,标题为 R2.3 新建工作验收,只记录测试计划。', }); @@ -2318,6 +2366,7 @@ test('English explicit creation extracts the requested Session name', async () = sessions.submit = async () => ({ turnId: 'turn-parser-cleanup' }); const result = await createWorkHubController({ sessions }).submit({ + newSessionFallbackTitle: '新工作', requestId: 'english-explicit-new', text: 'Create a new session called Parser Cleanup.', }); @@ -2455,6 +2504,7 @@ test('English routing boilerplate does not make an old analysis look related', a sessions.submit = async () => ({ turnId: 'turn-payment-new' }); const result = await createWorkHubController({ sessions }).submit({ + newSessionFallbackTitle: '新工作', requestId: 'english-boilerplate', text: "Check payment callback duplicate delivery; just analyze the risks and test cases; don't modify any files.", }); @@ -2474,10 +2524,12 @@ test('negated and deliberative creation language never creates a Session', async const controller = createWorkHubController({ sessions }); const negated = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'negated-create', text: '不要创建一个新任务,我们先讨论这个方向。', }); const deliberative = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'question-create', text: '是否应该新建一个任务?', }); @@ -2591,6 +2643,7 @@ test('polite executable questions and file-level constraints still create new wo for (const text of cases) { assert.equal( createWorkHubRoutePolicy().resolve({ + newSessionFallbackTitle: '新工作', text, sessions: [], originPromptBySessionId: new Map(), @@ -2617,6 +2670,7 @@ test('advisory how-to ambiguity asks for a direct instruction', () => { ]) { assert.deepEqual( createWorkHubRoutePolicy().resolve({ + newSessionFallbackTitle: '新工作', text, sessions: [], originPromptBySessionId: new Map(), @@ -2634,6 +2688,7 @@ test('advisory ambiguity overrides explicit, exact-name, and recent-focus routin assert.deepEqual( createWorkHubRoutePolicy().resolve({ + newSessionFallbackTitle: '新工作', text, sessions: [login], originPromptBySessionId: new Map(), @@ -2643,6 +2698,7 @@ test('advisory ambiguity overrides explicit, exact-name, and recent-focus routin ); assert.deepEqual( createWorkHubRoutePolicy().resolve({ + newSessionFallbackTitle: '新工作', text, sessions: [login], originPromptBySessionId: new Map(), @@ -2654,6 +2710,7 @@ test('advisory ambiguity overrides explicit, exact-name, and recent-focus routin focusedPolicy.rememberTarget(login.target); assert.deepEqual( focusedPolicy.resolve({ + newSessionFallbackTitle: '新工作', text: 'Explain how to diagnose this, then fix it.', sessions: [login], originPromptBySessionId: new Map(), @@ -2696,6 +2753,7 @@ test('literal negator targets still create new work', () => { ]) { assert.equal( createWorkHubRoutePolicy().resolve({ + newSessionFallbackTitle: '新工作', text, sessions: [], originPromptBySessionId: new Map(), @@ -2757,6 +2815,7 @@ test('withdrawing the requested action keeps the input in WorkHub', () => { ]) { assert.equal( createWorkHubRoutePolicy().resolve({ + newSessionFallbackTitle: '新工作', text, sessions: [], originPromptBySessionId: new Map(), @@ -2783,6 +2842,7 @@ test('a later affirmative clause creates work after withdrawing an earlier actio ]) { assert.equal( createWorkHubRoutePolicy().resolve({ + newSessionFallbackTitle: '新工作', text, sessions: [], originPromptBySessionId: new Map(), @@ -2876,6 +2936,7 @@ test('a correction with a negated creation tail never proposes a new Session', ( const policy = createWorkHubRoutePolicy(); policy.rememberTarget(payment.target); const decision = policy.resolve({ + newSessionFallbackTitle: '新工作', text, sessions: [login, payment], originPromptBySessionId: new Map(), @@ -2893,6 +2954,7 @@ test('a pronoun correction uses the shared affirmative target span', () => { assert.deepEqual( policy.resolve({ + newSessionFallbackTitle: '新工作', text: 'Not this session; move it to Payments', sessions: [source, payments], originPromptBySessionId: new Map(), @@ -2923,6 +2985,7 @@ test('correction routing preserves quoted and punctuated Session identities', () const policy = createWorkHubRoutePolicy(); policy.rememberTarget(source.target); const decision = policy.resolve({ + newSessionFallbackTitle: '新工作', text, sessions: [source, target], originPromptBySessionId: new Map(), @@ -2961,6 +3024,7 @@ test('a negated existing-target correction never proposes destructive replacemen const policy = createWorkHubRoutePolicy(); policy.rememberTarget(payment.target); const decision = policy.resolve({ + newSessionFallbackTitle: '新工作', text, sessions: [login, payment], originPromptBySessionId: new Map(), @@ -3179,6 +3243,7 @@ test('indirect questions containing action words stay in WorkHub', () => { ]) { assert.equal( createWorkHubRoutePolicy().resolve({ + newSessionFallbackTitle: '新工作', text, sessions: [], originPromptBySessionId: new Map(), @@ -3199,6 +3264,7 @@ test('a fuzzy correction target never becomes destructive routing authority', () policy.rememberTarget(source.target); const decision = policy.resolve({ + newSessionFallbackTitle: '新工作', text: '不是这个,换成支付页面', sessions: [source, paymentCallback], originPromptBySessionId: new Map(), @@ -3243,6 +3309,7 @@ test('a candidate name cannot absorb unquoted withdrawal semantics', () => { const policy = createWorkHubRoutePolicy(); policy.rememberTarget(source.target); const decision = policy.resolve({ + newSessionFallbackTitle: '新工作', text, sessions: [source, candidate], originPromptBySessionId: new Map(), @@ -3279,6 +3346,7 @@ test('malformed or unbound creation naming stays in WorkHub discussion', () => { ]) { assert.equal( createWorkHubRoutePolicy().resolve({ + newSessionFallbackTitle: '新工作', text, sessions: [], originPromptBySessionId: new Map(), diff --git a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts index 772fb55f39..86393668fe 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -32,6 +32,7 @@ import { submitWorkHubSurfaceInput, visibleWorkHubConversation, workHubAmbiguousCommandPrompt, + workHubCoordinationSummary, workHubSurfaceFailure, workHubSubmissionClearsDraft, } from '../../renderer/workhub-surface.js'; @@ -43,30 +44,37 @@ import { type WorkHubDelegationExecutionState, type WorkHubSubmitInput, } from '../../renderer/workhub-controller.js'; +import { ExpectedOperationError } from '../../renderer/application/contracts/operation-diagnostics.js'; +import { WorkHubCoordinationFailure } from '../../renderer/workhub-coordination-port.js'; import { WorkHubSendLease } from '../../renderer/workhub-send-lease.js'; +import { getWorkHubCopy } from '../../renderer/locales/workhub-copy.js'; import { createDesktopWorkHubSessionPort, type WorkHubDesktopSession, } from '../../renderer/workhub-session-port.js'; -test('surface turns Action Gate rejections into safe actionable failures', () => { +test('surface turns Action Gate rejections into safe actionable failures', (context) => { + context.mock.method(console, 'error', () => undefined); assert.equal( - workHubSurfaceFailure( - new Error('WorkHub Session candidates changed; refresh before delegating'), - ), + workHubSurfaceFailure(new ExpectedOperationError('candidates_changed')), 'candidates_changed', ); assert.equal( - workHubSurfaceFailure( - new Error('WorkHub linked correction requires an active durable delegation'), - ), + workHubSurfaceFailure(new ExpectedOperationError('linked_correction_unavailable')), 'linked_correction_unavailable', ); assert.equal( - workHubSurfaceFailure(new Error('Target Session is waiting for user input')), + workHubSurfaceFailure(new WorkHubCoordinationFailure('session_busy', 'Host diagnostic')), 'target_waiting', ); - assert.equal(workHubSurfaceFailure(new Error('private transport detail')), 'delivery_failed'); + assert.equal( + workHubSurfaceFailure(new WorkHubCoordinationFailure('operation_conflict', 'Host diagnostic')), + 'action_changed', + ); + assert.equal( + workHubSurfaceFailure(new Error('WorkHub Session candidates changed; private detail')), + 'delivery_failed', + ); }); test('surface route gate rejects same-frame duplicate operations and reopens after settle', async () => { @@ -281,6 +289,31 @@ test('surface keeps the Composer draft when routing fails or the target is waiti ); }); +for (const [locale, expected] of [ + ['zh-CN', '这项工作正在等待你的决定。 新请求尚未发送;处理原 Session 中的交互后可以再次发送。'], + ['zh-TW', '這項工作正在等待你的決定。 新請求尚未傳送;處理原 Session 中的互動後可以再次傳送。'], + ['en', 'This work is waiting for your decision. The new request was not sent. Resolve the interaction in its Session, then send again.'], +] as const) { + test(`${locale} waiting summary is a complete message independent of the separate UI paragraphs`, () => { + const result = { + kind: 'waiting' as const, + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: 'waiting', + text: 'Continue payments', + target: { sessionId: 'payment' }, + }; + const projection = { sessions: [], turns: [] }; + const copy = getWorkHubCopy(locale); + + assert.equal(workHubCoordinationSummary(result, projection, copy), expected); + assert.equal(workHubCoordinationSummary(result, projection, { + ...copy, + waitingForDecision: 'Standalone status paragraph', + requestNotSent: 'Standalone retry hint', + }), expected); + }); +} + test('surface replaces a local discussion placeholder with its durable model answer', () => { const local = [ { @@ -446,13 +479,14 @@ test('surface keeps clarification and successful routing in WorkHub', async () = const clarification = await submitWorkHubSurfaceInput({ controller, - input: { requestId: 'request-1', text: '继续处理重复问题' }, + input: { newSessionFallbackTitle: '新工作', requestId: 'request-1', text: '继续处理重复问题' }, }); assert.equal(clarification.kind, 'clarification'); const submitted = await submitWorkHubSurfaceInput({ controller, input: { + newSessionFallbackTitle: '新工作', requestId: 'request-1', text: '继续处理重复问题', explicitTarget: { sessionId: 'payment' }, @@ -511,7 +545,7 @@ test('ambiguous creation is durably clarified before a fresh imperative creates for (const [index, text] of ambiguousTexts.entries()) { const ambiguous = await submitAndRecordWorkHubSurfaceInput({ controller, - request: { requestId: `ambiguous-request-${index}`, text }, + request: { newSessionFallbackTitle: '新工作', requestId: `ambiguous-request-${index}`, text }, recordedUserText: text, summary: () => workHubAmbiguousCommandPrompt('en'), onSummaryError: () => assert.fail('clarification should be durable'), @@ -534,7 +568,7 @@ test('ambiguous creation is durably clarified before a fresh imperative creates const submitted = await submitAndRecordWorkHubSurfaceInput({ controller, - request: { requestId: 'direct-request', text: 'Fix login.' }, + request: { newSessionFallbackTitle: '新工作', requestId: 'direct-request', text: 'Fix login.' }, recordedUserText: 'Fix login.', summary: () => 'unused', onSummaryError: () => assert.fail('submitted work is projected from its assignment'), @@ -569,7 +603,7 @@ test('surface leaves discussion in WorkHub instead of creating a task view', asy const result = await submitWorkHubSurfaceInput({ controller, - input: { requestId: 'discussion', text: '这个方向的价值是什么?' }, + input: { newSessionFallbackTitle: '新工作', requestId: 'discussion', text: '这个方向的价值是什么?' }, }); assert.equal(result.kind, 'discussion'); @@ -744,18 +778,22 @@ test('real Session projection creates new guide topics and preserves origin ambi }); const payment = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'setup-payment', text: '检查支付回调重复投递时的幂等性,先只分析风险和测试点,不修改文件。', }); const layout = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'setup-layout', text: '优化 WorkHub 在移动端窄屏下的消息布局,先给设计建议,不修改文件。', }); await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'focus-login', text: '刷新令牌过期致重复登录的排查计划:补充观测日志字段。', }); const ambiguous = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'ambiguous-repeat', text: '继续处理重复问题', }); @@ -829,7 +867,7 @@ test('successful delegated submission needs no renderer summary write', async () }); const result = await submitAndRecordWorkHubSurfaceInput({ controller, - request: { requestId: 'action-1', text: 'Continue payments' }, + request: { newSessionFallbackTitle: '新工作', requestId: 'action-1', text: 'Continue payments' }, recordedUserText: 'Continue payments', summary: () => 'Sent to Payments', onSummaryError: () => assert.fail('no summary write is expected'), @@ -849,7 +887,7 @@ test('resume records ordinary conversation text without persisting execution fie }); await submitAndRecordWorkHubSurfaceInput({ controller, - request: { requestId: 'resume-1', text: 'Resume Payments' }, + request: { newSessionFallbackTitle: '新工作', requestId: 'resume-1', text: 'Resume Payments' }, recordedUserText: 'Resume Payments', summary: () => 'Resume requested. See the target Session for current progress.', onSummaryError: () => assert.fail('conversation write must succeed'), diff --git a/apps/desktop/src/main/attachment-ingest.ts b/apps/desktop/src/main/attachment-ingest.ts index 446cb99fb6..997b16f61b 100644 --- a/apps/desktop/src/main/attachment-ingest.ts +++ b/apps/desktop/src/main/attachment-ingest.ts @@ -21,6 +21,7 @@ import { Buffer } from 'node:buffer'; import { open } from 'node:fs/promises'; import { basename } from 'node:path'; import { + attachmentIngestBlocked, attachmentKindFromMimeType, MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_COUNT, @@ -154,7 +155,7 @@ export async function readFileCapped(path: string, maxBytes: number): Promise maxBytes) throw new Error('单个附件超出大小限制。'); + if (bytesRead > maxBytes) throw attachmentIngestBlocked('item_too_large'); return buf.subarray(0, bytesRead); } finally { await fh.close(); @@ -188,23 +189,23 @@ export async function resolveIngestItems(input: { }): Promise { const maxAttachments = input.maxAttachments ?? MAX_ATTACHMENT_COUNT; const maxBytes = input.maxBytes ?? MAX_ATTACHMENT_BYTES; - if (!Array.isArray(input.items)) throw new Error('附件信息无效,请重新选择文件后再发送。'); - if (input.items.length > maxAttachments) throw new Error('一次最多添加 8 个附件。'); + if (!Array.isArray(input.items)) throw attachmentIngestBlocked('items_invalid'); + if (input.items.length > maxAttachments) throw attachmentIngestBlocked('count_limit'); // Phase 1: validate every item with no side effects. Approval tokens are // peeked (not consumed) so a later invalid item does not burn earlier ones. const planned: AttachmentIngestFile[] = []; const approvalIds: string[] = []; const seenApprovalIds = new Set(); for (const item of input.items) { - if (!item || typeof item !== 'object') throw new Error('附件信息无效,请重新选择文件后再发送。'); + if (!item || typeof item !== 'object') throw attachmentIngestBlocked('items_invalid'); const record = item as Record; if (typeof record.approvalId === 'string' && typeof record.name === 'string') { - if (seenApprovalIds.has(record.approvalId)) throw new Error('附件来源重复,请勿重复添加同一文件。'); + if (seenApprovalIds.has(record.approvalId)) throw attachmentIngestBlocked('duplicate_source'); seenApprovalIds.add(record.approvalId); const approved = input.approvals.peekApproval(input.senderId, record.approvalId); - if (!approved) throw new Error('附件来源已过期或无效,请重新选择文件后再发送。'); + if (!approved) throw attachmentIngestBlocked('source_expired'); const statResult = await input.stat(approved.path); - if (statResult.size > maxBytes) throw new Error('单个附件超出大小限制。'); + if (statResult.size > maxBytes) throw attachmentIngestBlocked('item_too_large'); const mimeType = pickMimeType(record.mimeType, approved.mimeType); planned.push({ path: approved.path, ...(mimeType ? { mimeType } : {}), size: statResult.size }); approvalIds.push(record.approvalId); @@ -215,21 +216,21 @@ export async function resolveIngestItems(input: { // string must not be decoded into main memory. base64 encodes 3 bytes // per 4 chars, so ceil(maxBytes*4/3)+padding is a safe upper bound. const maxBase64Len = Math.ceil((maxBytes * 4) / 3) + 4; - if (record.base64.length > maxBase64Len) throw new Error('单个附件超出大小限制。'); + if (record.base64.length > maxBase64Len) throw attachmentIngestBlocked('item_too_large'); const content = Buffer.from(record.base64, 'base64'); - if (content.byteLength > maxBytes) throw new Error('单个附件超出大小限制。'); + if (content.byteLength > maxBytes) throw attachmentIngestBlocked('item_too_large'); const mimeType = typeof record.mimeType === 'string' && record.mimeType.length > 0 ? record.mimeType : undefined; planned.push({ name: record.name, ...(mimeType ? { mimeType } : {}), size: content.byteLength, content }); continue; } - throw new Error('附件信息无效,请重新选择文件后再发送。'); + throw attachmentIngestBlocked('items_invalid'); } // Phase 2: consume all approval tokens now that every item validated. Peek // passed, so each consume succeeds unless a concurrent request raced on the // same token; in that rare case we surface it as an expired-token error. for (const id of approvalIds) { if (!input.approvals.consumeApproval(input.senderId, id)) { - throw new Error('附件来源已过期或无效,请重新选择文件后再发送。'); + throw attachmentIngestBlocked('source_expired'); } } return planned; diff --git a/apps/desktop/src/main/runtime-host-guest-session-mounts.ts b/apps/desktop/src/main/runtime-host-guest-session-mounts.ts index 53bc28bc98..e6ea6bafa0 100644 --- a/apps/desktop/src/main/runtime-host-guest-session-mounts.ts +++ b/apps/desktop/src/main/runtime-host-guest-session-mounts.ts @@ -18,6 +18,7 @@ */ import { randomUUID } from 'node:crypto'; +import { redactSecrets } from '@maka/core/redaction'; import { abortable, decodeRemoteRuntimeHostProfile, @@ -655,8 +656,8 @@ export function createDesktopGuestSessionMountService(input: { if (!retained) { return { kind: 'error', - reason: 'connection_failed', - message: `At most ${MAX_MOUNTS} shared Sessions can be retained`, + reason: 'mount_limit_reached', + params: { max: MAX_MOUNTS }, }; } let reconcile = false; @@ -673,6 +674,8 @@ export function createDesktopGuestSessionMountService(input: { mountId: mount.mountId, }; } catch (error) { + const importWasCancelled = activation.controller.signal.aborted; + const peerPathUnavailable = isPeerPathUnavailable(error); if ( (activation.stage === 'finalizing' && error instanceof RuntimeHostPairingFinalizationInterruptedError) || @@ -689,13 +692,22 @@ export function createDesktopGuestSessionMountService(input: { await input.unmount(mount.mountId).catch(() => undefined); invalidatingAccess.delete(mount.mountId); } + if (!reconcile && !importWasCancelled && !peerPathUnavailable) { + const diagnostic = asError(error); + console.error( + '[session-collaboration] import failed:', + redactSecrets(diagnostic.stack ?? diagnostic.message), + ); + } return reconcile ? { kind: 'recovering', mountId: mount.mountId } : { kind: 'error', - reason: error instanceof RuntimeHostRemoteCompatibilityError ? 'incompatible_host' - : isPeerPathUnavailable(error) ? 'peer_path_unavailable' : 'connection_failed', - message: asError(error).message, + reason: error instanceof RuntimeHostRemoteCompatibilityError + ? 'incompatible_host' + : peerPathUnavailable + ? 'peer_path_unavailable' + : 'connection_failed', }; } finally { activations.delete(activation); diff --git a/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts index a68664aac7..ce63f9079d 100644 --- a/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts @@ -26,9 +26,14 @@ import type { } from '@maka/runtime/stream-graph-read-model'; import { DEFAULT_MAX_ITERATIONS, type GoalState } from '@maka/runtime/goal-state'; import type { ShellRunPtyDataEvent } from '@maka/runtime/shell-run-contract'; -import type { - GoalProjection, - SessionDomainChange, +import type { PlanSessionState } from '@maka/core/plan'; +import { + RuntimeHostOperationError, + type GoalProjection, + type OperationOutput, + type PlanControlErrorCode, + type PlanControlIpcResult, + type SessionDomainChange, } from '@maka/runtime-host/protocol'; import type { AgentGraphEpochDirectory } from '@maka/runtime-host/client'; import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; @@ -189,86 +194,119 @@ export function registerRuntimeHostSessionDomainsIpc( ); ipcMain.handle( 'plan-mode:requestRevision', - async (_event, sessionId: unknown, proposalId: unknown) => { + async (_event, sessionId: unknown, proposalId: unknown): Promise> => { const normalizedSessionId = requiredId(sessionId, 'Session'); - await deps.client.controlPlan({ - kind: 'request_revision', - sessionId: normalizedSessionId, - proposalId: requiredId(proposalId, 'Plan proposal'), - operationId: newId(), - }); + try { + await deps.client.controlPlan({ + kind: 'request_revision', + sessionId: normalizedSessionId, + proposalId: requiredId(proposalId, 'Plan proposal'), + operationId: newId(), + }); + } catch (error) { + const failure = planControlIpcFailure(error); + if (failure) return failure; + throw error; + } deps.emitModeChanged(normalizedSessionId); - return deps.client.getPlanState(normalizedSessionId); + return { ok: true, value: await deps.client.getPlanState(normalizedSessionId) }; }, ); ipcMain.handle( 'plan-mode:abandon', - async (_event, sessionId: unknown, proposalId: unknown) => { + async (_event, sessionId: unknown, proposalId: unknown): Promise> => { const normalizedSessionId = requiredId(sessionId, 'Session'); - await deps.client.controlPlan({ - kind: 'abandon_proposal', - sessionId: normalizedSessionId, - proposalId: requiredId(proposalId, 'Plan proposal'), - operationId: newId(), - }); + try { + await deps.client.controlPlan({ + kind: 'abandon_proposal', + sessionId: normalizedSessionId, + proposalId: requiredId(proposalId, 'Plan proposal'), + operationId: newId(), + }); + } catch (error) { + const failure = planControlIpcFailure(error); + if (failure) return failure; + throw error; + } deps.emitModeChanged(normalizedSessionId); - return deps.client.getPlanState(normalizedSessionId); + return { ok: true, value: await deps.client.getPlanState(normalizedSessionId) }; }, ); ipcMain.handle( 'plan-mode:approve', - async (_event, sessionId: unknown, value: unknown) => { + async (_event, sessionId: unknown, value: unknown): Promise> => { const normalizedSessionId = requiredId(sessionId, 'Session'); const input = planApprovalInput(value); - const result = await deps.client.startPlanTurn({ - kind: 'approve_proposal', - sessionId: normalizedSessionId, - proposalId: input.proposalId, - expectedRevision: input.expectedRevision, - expectedStoreVersion: input.expectedStoreVersion, - turnId: input.turnId, - }); - if (result.plan.executionId === null) { + const result = await deps.client + .startPlanTurn({ + kind: 'approve_proposal', + sessionId: normalizedSessionId, + proposalId: input.proposalId, + expectedRevision: input.expectedRevision, + expectedStoreVersion: input.expectedStoreVersion, + turnId: input.turnId, + }) + .then( + (output): PlanControlIpcResult> => ({ + ok: true, + value: output, + }), + (error: unknown) => planControlIpcFailure(error) ?? Promise.reject(error), + ); + if (!result.ok) return result; + if (result.value.plan.executionId === null) { throw new Error('Plan approval did not create an execution'); } deps.emitModeChanged(normalizedSessionId); return { - turnId: input.turnId, - executionId: result.plan.executionId, + ok: true, + value: { turnId: input.turnId, executionId: result.value.plan.executionId }, }; }, ); ipcMain.handle( 'plan-mode:resume', - async (_event, sessionId: unknown, executionId: unknown, turnId: unknown) => { + async (_event, sessionId: unknown, executionId: unknown, turnId: unknown): Promise> => { const normalizedSessionId = requiredId(sessionId, 'Session'); const normalizedExecutionId = requiredId(executionId, 'Plan execution'); const normalizedTurnId = requiredId(turnId, 'Turn'); - await deps.client.startPlanTurn({ - kind: 'resume_execution', - sessionId: normalizedSessionId, - executionId: normalizedExecutionId, - turnId: normalizedTurnId, - }); + try { + await deps.client.startPlanTurn({ + kind: 'resume_execution', + sessionId: normalizedSessionId, + executionId: normalizedExecutionId, + turnId: normalizedTurnId, + }); + } catch (error) { + const failure = planControlIpcFailure(error); + if (failure) return failure; + throw error; + } deps.emitModeChanged(normalizedSessionId); return { - turnId: normalizedTurnId, - executionId: normalizedExecutionId, + ok: true, + value: { turnId: normalizedTurnId, executionId: normalizedExecutionId }, }; }, ); ipcMain.handle( 'plan-mode:abandonExecution', - async (_event, sessionId: unknown, executionId: unknown) => { + async (_event, sessionId: unknown, executionId: unknown): Promise> => { const normalizedSessionId = requiredId(sessionId, 'Session'); - await deps.client.controlPlan({ - kind: 'cancel_execution', - sessionId: normalizedSessionId, - executionId: requiredId(executionId, 'Plan execution'), - operationId: newId(), - }); + try { + await deps.client.controlPlan({ + kind: 'cancel_execution', + sessionId: normalizedSessionId, + executionId: requiredId(executionId, 'Plan execution'), + operationId: newId(), + }); + } catch (error) { + const failure = planControlIpcFailure(error); + if (failure) return failure; + throw error; + } deps.emitModeChanged(normalizedSessionId); - return deps.client.getPlanState(normalizedSessionId); + return { ok: true, value: await deps.client.getPlanState(normalizedSessionId) }; }, ); handleReconnectableRead( @@ -555,3 +593,20 @@ function planApprovalInput(value: unknown): { turnId: requiredId(record.turnId, 'Turn'), }; } + +/** + * Convert a thrown plan-control rejection into the IPC envelope: expected + * plan-control codes keep their structured code across the boundary, and any + * other failure keeps rejecting so it takes the unexpected-diagnostics path. + */ +function planControlIpcFailure( + error: unknown, +): { readonly ok: false; readonly error: { readonly code: PlanControlErrorCode; readonly message: string } } | undefined { + if (error instanceof RuntimeHostOperationError && error.operation.startsWith('plan.')) { + return { + ok: false, + error: { code: error.code as PlanControlErrorCode, message: error.message }, + }; + } + return undefined; +} diff --git a/apps/desktop/src/main/work-board-ipc-main.ts b/apps/desktop/src/main/work-board-ipc-main.ts index 780c35e196..10d7a85c9d 100644 --- a/apps/desktop/src/main/work-board-ipc-main.ts +++ b/apps/desktop/src/main/work-board-ipc-main.ts @@ -19,6 +19,7 @@ import type { IpcMain } from 'electron'; import type { WorkBoardItem, WorkBoardPage } from '@maka/core/work-board'; +import { redactSecrets } from '@maka/core/redaction'; import { createWorkBoardStore, WorkBoardStoreError, @@ -166,14 +167,12 @@ function requireWorkBoardId(id: unknown): string { } function workBoardFailure(error: unknown): { - readonly code: WorkBoardStoreErrorCode | 'unknown'; - readonly message: string; + readonly error: { readonly code: WorkBoardStoreErrorCode | 'unknown' }; } { - if (error instanceof WorkBoardStoreError) { - return { code: error.code, message: error.message }; + if (error instanceof WorkBoardStoreError && error.code !== 'corrupt_record') { + return { error: { code: error.code } }; } - return { - code: 'unknown', - message: error instanceof Error ? error.message : 'Work Board operation failed', - }; + const detail = error instanceof Error ? error.stack ?? error.message : String(error); + console.error('[work-board] operation failed:', redactSecrets(detail)); + return { error: { code: error instanceof WorkBoardStoreError ? error.code : 'unknown' } }; } diff --git a/apps/desktop/src/preload/attachment-ingest-payload.ts b/apps/desktop/src/preload/attachment-ingest-payload.ts index c36848a7f0..f18eca7b33 100644 --- a/apps/desktop/src/preload/attachment-ingest-payload.ts +++ b/apps/desktop/src/preload/attachment-ingest-payload.ts @@ -17,7 +17,11 @@ * under the License. */ -import { MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_COUNT } from '@maka/core/attachments'; +import { + attachmentIngestBlocked, + MAX_ATTACHMENT_BYTES, + MAX_ATTACHMENT_COUNT, +} from '@maka/core/attachments'; export type IngestInput = | { approvalId: string; name: string; mimeType?: string } @@ -37,14 +41,14 @@ function bytesToBase64(bytes: Uint8Array): string { } export async function encodeIngestItems(items: IngestInput[]): Promise { - if (items.length > MAX_ATTACHMENT_COUNT) throw new Error('附件数量超过 8 个'); + if (items.length > MAX_ATTACHMENT_COUNT) throw attachmentIngestBlocked('count_exceeded'); const out: IngestPayload[] = []; for (const item of items) { if ('file' in item) { // Reject oversized blobs before arrayBuffer() so the renderer never // loads the bytes into memory. Main-side resolveIngestItems is the // authoritative backstop; this guard exists only to avoid renderer OOM. - if (item.file.size > MAX_ATTACHMENT_BYTES) throw new Error('附件大小超过 50MB'); + if (item.file.size > MAX_ATTACHMENT_BYTES) throw attachmentIngestBlocked('size_exceeded'); const bytes = new Uint8Array(await item.file.arrayBuffer()); const mimeType = item.file.type || undefined; out.push({ @@ -55,7 +59,7 @@ export async function encodeIngestItems(items: IngestInput[]): Promise; getPlanState(sessionId: string): Promise; subscribePlanChanges(sessionId: string, handler: () => void): () => void; - requestPlanRevision(sessionId: string, proposalId: string): Promise; - abandonPlanProposal( - sessionId: string, - proposalId: string, - ): Promise; + requestPlanRevision(sessionId: string, proposalId: string): Promise>; + abandonPlanProposal(sessionId: string, proposalId: string): Promise; approvePlan(sessionId: string, input: { proposalId: string; expectedRevision: number; expectedStoreVersion: number; turnId: string; - }): Promise<{ turnId: string; executionId: string }>; - resumePlan(sessionId: string, executionId: string, turnId: string): Promise<{ + }): Promise>; + resumePlan(sessionId: string, executionId: string, turnId: string): Promise; - abandonPlanExecution(sessionId: string, executionId: string): Promise; + }>>; + abandonPlanExecution(sessionId: string, executionId: string): Promise>; setModelConfiguration(sessionId: string, input: { llmConnectionId: string; llmConnectionSlug: string; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 974ffa10fa..d15e2d15b4 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -232,6 +232,7 @@ import { type CollaborationTurnRequestQueryResult, type CollaborationTurnRequestWithdrawResult, type SessionTurnAccessRequest, + type PlanControlIpcResult, } from '@maka/runtime-host/protocol'; import type { AgentGraphEpochDirectory } from '@maka/runtime-host/client'; import { @@ -1266,15 +1267,11 @@ function executeWebSearchQuery(input: { return Promise.resolve(unsupportedWebSearchProvider()); } if (input.provider === 'model') { - return Promise.resolve({ - ok: false, - reason: 'unsupported_provider', - message: '原生联网搜索由任务中的主模型请求执行,不支持从设置页单独调用。', - }); + return Promise.resolve({ ok: false, reason: 'unsupported_provider' }); } const query = normalizeWebSearchQuery(input.query); if (!query) { - return Promise.resolve({ ok: false, reason: 'invalid_query', message: '请输入有效的搜索关键词。' }); + return Promise.resolve({ ok: false, reason: 'invalid_query' }); } const apiKey = webSearchCredentialOverride(input.apiKey); return selectedRuntimeHostScope(host).then((scope) => @@ -1294,11 +1291,7 @@ function executeWebSearchTest(input: { return Promise.resolve(unsupportedWebSearchProvider()); } if (input.provider === 'model') { - return Promise.resolve({ - ok: false, - reason: 'unsupported_provider', - message: '原生联网搜索由任务中的主模型请求执行,不需要单独测试搜索凭据。', - }); + return Promise.resolve({ ok: false, reason: 'unsupported_provider' }); } const apiKey = webSearchCredentialOverride(input.apiKey); return selectedRuntimeHostScope(host).then((scope) => @@ -1310,11 +1303,7 @@ function executeWebSearchTest(input: { } function unsupportedWebSearchProvider(): WebSearchResponse { - return { - ok: false, - reason: 'unsupported_provider', - message: '当前配置不支持这个搜索引擎,请选择 Tavily 后重试。', - }; + return { ok: false, reason: 'unsupported_provider' }; } function webSearchCredentialOverride(value: unknown): string | undefined { @@ -2382,30 +2371,37 @@ const makaBridge = { unsubscribe(); }; }, - requestPlanRevision(sessionId: string, proposalId: string): Promise { + requestPlanRevision(sessionId: string, proposalId: string): Promise> { return invokeProjectedSessionRuntimeHost('plan-mode:requestRevision', sessionId, proposalId); }, abandonPlanProposal( sessionId: string, proposalId: string, ): Promise { - return invokeProjectedSessionRuntimeHost('plan-mode:abandon', sessionId, proposalId); + // The app-shell exit path keeps the throwing shape it had before the + // envelope landed; the renderer's session-setting intent renders the + // generic failure copy through sessionSettingFailureCopy('plan', ...). + return invokeProjectedSessionRuntimeHost>('plan-mode:abandon', sessionId, proposalId) + .then((result) => { + if (result.ok) return result.value; + throw new Error(`plan_control:${result.error.code}:${result.error.message}`); + }); }, approvePlan(sessionId: string, input: { proposalId: string; expectedRevision: number; expectedStoreVersion: number; turnId: string; - }): Promise<{ turnId: string; executionId: string }> { + }): Promise> { return invokeSessionRuntimeHost('plan-mode:approve', sessionId, input); }, - resumePlan(sessionId: string, executionId: string, turnId: string): Promise<{ + resumePlan(sessionId: string, executionId: string, turnId: string): Promise { + }>> { return invokeSessionRuntimeHost('plan-mode:resume', sessionId, executionId, turnId); }, - abandonPlanExecution(sessionId: string, executionId: string): Promise { + abandonPlanExecution(sessionId: string, executionId: string): Promise> { return invokeProjectedSessionRuntimeHost('plan-mode:abandonExecution', sessionId, executionId); }, setModelConfiguration(sessionId: string, input: { diff --git a/apps/desktop/src/renderer/app-shell-command-actions.ts b/apps/desktop/src/renderer/app-shell-command-actions.ts index 61a60dc34d..5a60c6712c 100644 --- a/apps/desktop/src/renderer/app-shell-command-actions.ts +++ b/apps/desktop/src/renderer/app-shell-command-actions.ts @@ -183,7 +183,6 @@ export function buildAppShellCommandList( commandPaletteActionErrorMessage( err, copy.connectionUnavailable, - options.uiLocale, ), undefined, defaultRuntimeHostDiagnosticTarget(err), @@ -205,7 +204,6 @@ export function buildAppShellCommandList( commandPaletteActionErrorMessage( err, copy.setDefaultFallback, - options.uiLocale, ), undefined, defaultRuntimeHostDiagnosticTarget(err), @@ -287,7 +285,6 @@ export function buildAppShellCommandList( commandPaletteActionErrorMessage( err, copy.exportFallback, - options.uiLocale, ), ); } @@ -307,7 +304,6 @@ export function buildAppShellCommandList( commandPaletteActionErrorMessage( err, copy.memoryOpenFallback, - options.uiLocale, ), undefined, defaultRuntimeHostDiagnosticTarget(err), @@ -350,7 +346,6 @@ export function buildAppShellCommandList( commandPaletteActionErrorMessage( err, copy.clipboardDenied, - options.uiLocale, ), undefined, target, @@ -381,7 +376,6 @@ export function buildAppShellCommandList( commandPaletteActionErrorMessage( err, copy.networkTestFallback, - options.uiLocale, ), undefined, defaultRuntimeHostDiagnosticTarget(err), diff --git a/apps/desktop/src/renderer/app-shell-copy.ts b/apps/desktop/src/renderer/app-shell-copy.ts index 8e5d7c82fb..dfd291ba99 100644 --- a/apps/desktop/src/renderer/app-shell-copy.ts +++ b/apps/desktop/src/renderer/app-shell-copy.ts @@ -20,33 +20,23 @@ import type { ConnectionTestResult } from '@maka/core/llm-connections'; import type { TextFileImportPreflightFailureReason } from '@maka/core/text-file-import'; import type { UiLocale } from '@maka/core/ui-locale'; -import { generalizedErrorMessageForLocale } from '@maka/core/redaction'; +import { unexpectedErrorFallback } from './application/contracts/operation-diagnostics.js'; import { getShellCopy } from './locales/shell-copy.js'; -const SESSION_READ_MESSAGES_ERROR_MARKER = 'MAKA_SESSION_READ_MESSAGES_ERROR:'; - export function messageReadErrorMessage(error: unknown, locale: UiLocale): string { - return sessionMessageErrorMessage(error, getShellCopy(locale).errors.messageRead, locale); + return unexpectedErrorFallback(error, getShellCopy(locale).errors.messageRead, 'message-read'); } export function messageRefreshErrorMessage(error: unknown, locale: UiLocale): string { - return sessionMessageErrorMessage(error, getShellCopy(locale).errors.messageRefresh, locale); -} - -function sessionMessageErrorMessage(error: unknown, fallback: string, locale: UiLocale): string { - const raw = error instanceof Error ? error.message : String(error); - const markerIndex = raw.indexOf(SESSION_READ_MESSAGES_ERROR_MARKER); - if (markerIndex < 0 || locale !== 'zh-CN') return localizedErrorMessage(error, fallback, locale); - const marked = raw.slice(markerIndex + SESSION_READ_MESSAGES_ERROR_MARKER.length).trim(); - return marked.split(/\r?\n/, 1)[0]?.trim() || fallback; -} - -function localizedErrorMessage(error: unknown, fallback: string, locale: UiLocale): string { - return generalizedErrorMessageForLocale(error, fallback, locale); + return unexpectedErrorFallback( + error, + getShellCopy(locale).errors.messageRefresh, + 'message-refresh', + ); } -export function commandPaletteActionErrorMessage(error: unknown, fallback: string, locale: UiLocale): string { - return localizedErrorMessage(error, fallback, locale); +export function commandPaletteActionErrorMessage(error: unknown, fallback: string): string { + return unexpectedErrorFallback(error, fallback, 'command-palette'); } export function openPathActionErrorMessage( @@ -55,13 +45,14 @@ export function openPathActionErrorMessage( locale: UiLocale, ): string { const copy = getShellCopy(locale); - return localizedErrorMessage(error, copy.errors.openPath(copy.paths[key]), locale); + return unexpectedErrorFallback(error, copy.errors.openPath(copy.paths[key]), `open-path:${key}`); } export function commandPaletteConnectionTestFailureMessage(result: ConnectionTestResult, locale: UiLocale): string { const fallback = commandPaletteConnectionTestFailureFallback(result, locale); - if (!result.errorMessage) return fallback; - return localizedErrorMessage(new Error(result.errorMessage), fallback, locale); + return result.errorMessage + ? unexpectedErrorFallback(result.errorMessage, fallback, 'connection-test') + : fallback; } function commandPaletteConnectionTestFailureFallback(result: ConnectionTestResult, locale: UiLocale): string { diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index ec4596a269..48ee039a66 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -2773,7 +2773,7 @@ function AppShellContent({ {workHubEnabled && navSelection.section === 'sessions' && activeId ? (