diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 4570aa0895..df69ae28b4 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -87,6 +87,8 @@ "src/renderer/locales/settings-web-search-copy.ts", "src/renderer/locales/shell-copy.ts", "src/renderer/locales/shell-remaining-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", @@ -418,7 +420,7 @@ "@maka/core/redaction": 1 }, "importSpecifiers": 2, - "nonTriviaTokens": 504 + "nonTriviaTokens": 465 }, "src/renderer/app-shell-detail-panel.tsx": { "importDeclarations": 0, @@ -896,7 +898,7 @@ "react": 1 }, "importSpecifiers": 147, - "nonTriviaTokens": 15588 + "nonTriviaTokens": 15584 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, @@ -1759,6 +1761,24 @@ "actionFactories": [], "dependencyPaths": {} }, + "src/renderer/locales/work-board-error-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, + "src/renderer/locales/workhub-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, "src/renderer/mcp-brand-contrast.ts": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -4497,6 +4517,7 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { + "./application/contracts/operation-diagnostics.js": 1, "@maka/core/work-board": 1, "react": 1 } @@ -4516,6 +4537,7 @@ "actionFactories": [], "dependencyPaths": { "./locales/conversation-copy.js": 1, + "./locales/work-board-error-copy.js": 1, "./use-work-board.js": 1, "@astryxdesign/core": 1, "@astryxdesign/core/Button": 1, @@ -4533,6 +4555,7 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { + "./application/contracts/operation-diagnostics.js": 1, "./workhub-route-policy.js": 1 } }, @@ -4624,6 +4647,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..1e5aae8a03 --- /dev/null +++ b/apps/desktop/src/main/__tests__/expected-error-presentation.test.ts @@ -0,0 +1,97 @@ +/* + * 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 { getDesktopConversationCopy } from '../../renderer/locales/conversation-copy.js'; +import { getWorkBoardErrorCopy } from '../../renderer/locales/work-board-error-copy.js'; +import { getSessionCollaborationCopy } from '../../renderer/locales/session-collaboration-copy.js'; +import { ExpectedOperationError } from '../../renderer/application/contracts/operation-diagnostics.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'; + +test('routes Work Board codes through each locale catalog', () => { + for (const locale of ['zh-CN', 'zh-TW', 'en'] as const) { + const copy = getDesktopConversationCopy(locale).workBoardPanel; + const errorCopy = getWorkBoardErrorCopy(locale); + const error: unknown = new ExpectedOperationError<'not_found'>('not_found'); + assert.equal( + error instanceof ExpectedOperationError && Object.hasOwn(errorCopy, error.code) + ? errorCopy[error.code as keyof typeof errorCopy] + : copy.actionFailed, + errorCopy.not_found, + ); + } +}); + +test('maps blocked session-control tokens per locale at the shared entry', () => { + const blocked = new Error('session_control_blocked:permission_turn_running'); + assert.equal( + localizedShellErrorMessage(blocked, 'fallback', 'zh-CN'), + '当前任务正在运行,等结束后再切换权限模式。', + ); + assert.equal( + localizedShellErrorMessage(blocked, 'fallback', 'en'), + 'A task is still running. Change the permission mode after it finishes.', + ); +}); + +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('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('uses localized fallbacks instead of classifying raw exception 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'), 'English fallback'); + assert.equal(localizedShellErrorMessage(raw, '中文兜底', 'zh-CN'), '中文兜底'); +}); 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__/runtime-host-guest-session-mounts.test.ts b/apps/desktop/src/main/__tests__/runtime-host-guest-session-mounts.test.ts index ce367beb73..f357c972bc 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 @@ -325,7 +325,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); }); @@ -672,6 +672,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__/work-board-ipc-main.test.ts b/apps/desktop/src/main/__tests__/work-board-ipc-main.test.ts index aaabba2f51..1af842e8b8 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 @@ -164,8 +164,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 +175,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 +196,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 13b46ce35c..7a0b7bb464 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -30,10 +30,12 @@ import { } from '../../renderer/workhub-controller.js'; import { createWorkHubRoutePolicy, - workHubNewSessionName, + workHubNewSessionName as workHubNewSessionNameForLocale, } from '../../renderer/workhub-route-policy.js'; import { WorkHubCoordinationFailure } from '../../renderer/workhub-coordination-port.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), @@ -365,7 +367,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, @@ -388,7 +390,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(); @@ -409,7 +411,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', @@ -446,7 +448,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', @@ -477,7 +479,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(); @@ -524,7 +526,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), @@ -632,6 +634,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: '支付回调幂等性现在是什么状态?', }); @@ -652,6 +655,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' }, @@ -683,6 +687,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: '在支付回调幂等性里补充重复投递测试', }); @@ -710,6 +715,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移动端消息布局:补充横屏注意点。', }); @@ -734,6 +740,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 的错误', }); @@ -760,6 +767,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} 的问题`, }); @@ -792,6 +800,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: '继续处理重复问题', }); @@ -843,12 +852,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: '继续处理重复问题', }); @@ -879,7 +890,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, { @@ -909,7 +920,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, { @@ -931,12 +942,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: '继续它', }); @@ -967,10 +980,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: '回到上一个工作', }); @@ -998,6 +1013,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: '继续这个工作', }); @@ -1041,6 +1057,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: '继续这个工作', }); @@ -1066,6 +1083,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: '继续这个工作', }); @@ -1095,6 +1113,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: '继续这个工作', }); @@ -1126,6 +1145,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: '继续这个工作', }); @@ -1155,6 +1175,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: '继续这个工作', }); @@ -1170,6 +1191,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: '继续这个工作', }); @@ -1211,6 +1233,7 @@ test('an old submit resolves against the visit focus captured before an await', }; const oldSubmission = controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-old-visit', text: '继续这个工作', }); @@ -1244,12 +1267,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: '继续处理支付回调重复投递', }); @@ -1284,6 +1309,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: '刷新令牌过期时,重复登录的观测日志应该记录哪些字段?', }); @@ -1312,7 +1338,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; @@ -1330,6 +1356,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: '继续登录', }); @@ -1351,6 +1378,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: '继续处理稳定性问题,但我不确定具体是哪一个。', }); @@ -1374,6 +1402,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.", }); @@ -1401,6 +1430,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', }); @@ -1429,6 +1459,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', }); @@ -1453,6 +1484,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: '排查令牌过期重复登录问题:补充一条等待状态下的新请求。', }); @@ -1479,17 +1511,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: '回到上一个工作', }); @@ -1517,12 +1552,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: '继续处理刷新令牌过期', }); @@ -1560,6 +1597,7 @@ test('submit keeps unmatched non-executable conversation in WorkHub', async () = }); const result = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'request-discussion', text: '你觉得统一入口最重要的价值是什么?', }); @@ -1617,6 +1655,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' }, @@ -1671,6 +1710,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' }, @@ -1730,6 +1770,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' }, @@ -1852,11 +1893,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: '不是这个,换成登录稳定性,补充刷新令牌失败判定', }); @@ -1864,7 +1907,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', ); @@ -1912,7 +1955,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'); } @@ -1986,6 +2029,7 @@ test('production creation leaves Session identity and workspace authority to mai }); const result = await controller.submit({ + newSessionFallbackTitle: '新工作', requestId: 'create-action', text: '请创建新任务,检查支付回调重复投递。', }); @@ -2014,6 +2058,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: '我们应该怎么实现统一入口?', }); @@ -2032,6 +2077,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', }); @@ -2056,6 +2102,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 功能', }); @@ -2087,6 +2134,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 新建工作验收,只记录测试计划。', }); @@ -2106,6 +2154,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.', }); @@ -2243,6 +2292,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.", }); @@ -2262,10 +2312,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: '是否应该新建一个任务?', }); @@ -2379,6 +2431,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(), @@ -2405,6 +2458,7 @@ test('advisory how-to ambiguity asks for a direct instruction', () => { ]) { assert.deepEqual( createWorkHubRoutePolicy().resolve({ + newSessionFallbackTitle: '新工作', text, sessions: [], originPromptBySessionId: new Map(), @@ -2422,6 +2476,7 @@ test('advisory ambiguity overrides explicit, exact-name, and recent-focus routin assert.deepEqual( createWorkHubRoutePolicy().resolve({ + newSessionFallbackTitle: '新工作', text, sessions: [login], originPromptBySessionId: new Map(), @@ -2431,6 +2486,7 @@ test('advisory ambiguity overrides explicit, exact-name, and recent-focus routin ); assert.deepEqual( createWorkHubRoutePolicy().resolve({ + newSessionFallbackTitle: '新工作', text, sessions: [login], originPromptBySessionId: new Map(), @@ -2442,6 +2498,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(), @@ -2484,6 +2541,7 @@ test('literal negator targets still create new work', () => { ]) { assert.equal( createWorkHubRoutePolicy().resolve({ + newSessionFallbackTitle: '新工作', text, sessions: [], originPromptBySessionId: new Map(), @@ -2545,6 +2603,7 @@ test('withdrawing the requested action keeps the input in WorkHub', () => { ]) { assert.equal( createWorkHubRoutePolicy().resolve({ + newSessionFallbackTitle: '新工作', text, sessions: [], originPromptBySessionId: new Map(), @@ -2571,6 +2630,7 @@ test('a later affirmative clause creates work after withdrawing an earlier actio ]) { assert.equal( createWorkHubRoutePolicy().resolve({ + newSessionFallbackTitle: '新工作', text, sessions: [], originPromptBySessionId: new Map(), @@ -2664,6 +2724,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(), @@ -2681,6 +2742,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(), @@ -2711,6 +2773,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(), @@ -2749,6 +2812,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(), @@ -2967,6 +3031,7 @@ test('indirect questions containing action words stay in WorkHub', () => { ]) { assert.equal( createWorkHubRoutePolicy().resolve({ + newSessionFallbackTitle: '新工作', text, sessions: [], originPromptBySessionId: new Map(), @@ -2987,6 +3052,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(), @@ -3031,6 +3097,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(), @@ -3067,6 +3134,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 8d9166794e..7600f9df3b 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -37,36 +37,42 @@ import { } from '../../renderer/workhub-surface.js'; import { createWorkHubController, + ExpectedOperationError, WORKHUB_ROUTING_STRATEGY_ID, type WorkHubController, type WorkHubCoordinationTurn, type WorkHubDelegationExecutionState, type WorkHubSubmitInput, } from '../../renderer/workhub-controller.js'; +import { WorkHubCoordinationFailure } from '../../renderer/workhub-coordination-port.js'; import { WorkHubSendLease } from '../../renderer/workhub-send-lease.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 () => { @@ -391,13 +397,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' }, @@ -456,7 +463,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'), @@ -479,7 +486,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'), @@ -514,7 +521,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'); @@ -681,18 +688,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: '继续处理重复问题', }); @@ -766,7 +777,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'), diff --git a/apps/desktop/src/main/attachment-ingest.ts b/apps/desktop/src/main/attachment-ingest.ts index 446cb99fb6..ec4e291b1b 100644 --- a/apps/desktop/src/main/attachment-ingest.ts +++ b/apps/desktop/src/main/attachment-ingest.ts @@ -154,7 +154,7 @@ export async function readFileCapped(path: string, maxBytes: number): Promise maxBytes) throw new Error('单个附件超出大小限制。'); + if (bytesRead > maxBytes) throw new Error('attachment_ingest:item_too_large'); return buf.subarray(0, bytesRead); } finally { await fh.close(); @@ -188,23 +188,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 new Error('attachment_ingest:items_invalid'); + if (input.items.length > maxAttachments) throw new Error('attachment_ingest: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 new Error('attachment_ingest: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 new Error('attachment_ingest:duplicate_source'); seenApprovalIds.add(record.approvalId); const approved = input.approvals.peekApproval(input.senderId, record.approvalId); - if (!approved) throw new Error('附件来源已过期或无效,请重新选择文件后再发送。'); + if (!approved) throw new Error('attachment_ingest:source_expired'); const statResult = await input.stat(approved.path); - if (statResult.size > maxBytes) throw new Error('单个附件超出大小限制。'); + if (statResult.size > maxBytes) throw new Error('attachment_ingest: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 +215,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 new Error('attachment_ingest:item_too_large'); const content = Buffer.from(record.base64, 'base64'); - if (content.byteLength > maxBytes) throw new Error('单个附件超出大小限制。'); + if (content.byteLength > maxBytes) throw new Error('attachment_ingest: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 new Error('attachment_ingest: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 new Error('attachment_ingest: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 a592272943..8859562177 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, @@ -649,8 +650,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; @@ -667,6 +668,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) || @@ -683,12 +686,18 @@ export function createDesktopGuestSessionMountService(input: { await input.unmount(mount.mountId).catch(() => undefined); invalidatedAccessMounts.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: isPeerPathUnavailable(error) ? 'peer_path_unavailable' : 'connection_failed', - message: asError(error).message, + reason: peerPathUnavailable ? 'peer_path_unavailable' : 'connection_failed', }; } finally { activations.delete(activation); diff --git a/apps/desktop/src/main/work-board-ipc-main.ts b/apps/desktop/src/main/work-board-ipc-main.ts index 780c35e196..ddd3dbe7c1 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 }; + 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: 'unknown' } }; } diff --git a/apps/desktop/src/preload/attachment-ingest-payload.ts b/apps/desktop/src/preload/attachment-ingest-payload.ts index c36848a7f0..f084ac3d1e 100644 --- a/apps/desktop/src/preload/attachment-ingest-payload.ts +++ b/apps/desktop/src/preload/attachment-ingest-payload.ts @@ -37,14 +37,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 new Error('attachment_ingest: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 new Error('attachment_ingest:size_exceeded'); const bytes = new Uint8Array(await item.file.arrayBuffer()); const mimeType = item.file.type || undefined; out.push({ @@ -55,7 +55,7 @@ export async function encodeIngestItems(items: IngestInput[]): Promise @@ -1289,11 +1285,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) => @@ -1305,11 +1297,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 { diff --git a/apps/desktop/src/renderer/app-shell-copy.ts b/apps/desktop/src/renderer/app-shell-copy.ts index 8e5d7c82fb..0ad4525a8e 100644 --- a/apps/desktop/src/renderer/app-shell-copy.ts +++ b/apps/desktop/src/renderer/app-shell-copy.ts @@ -20,33 +20,33 @@ 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 { getShellCopy } from './locales/shell-copy.js'; +import { redactSecrets } from '@maka/core/redaction'; -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); +function reportUnexpectedError(scope: string, error: unknown): void { + const detail = error instanceof Error ? (error.stack ?? `${error.name}: ${error.message}`) : String(error); + console.error(`[${scope}] operation failed:`, redactSecrets(detail)); } -export function messageRefreshErrorMessage(error: unknown, locale: UiLocale): string { - return sessionMessageErrorMessage(error, getShellCopy(locale).errors.messageRefresh, locale); +function unexpectedErrorFallback(error: unknown, fallback: string, scope: string): string { + reportUnexpectedError(scope, error); + return fallback; } -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; +export function messageReadErrorMessage(error: unknown, locale: UiLocale): string { + return unexpectedErrorFallback(error, getShellCopy(locale).errors.messageRead, 'message-read'); } -function localizedErrorMessage(error: unknown, fallback: string, locale: UiLocale): string { - return generalizedErrorMessageForLocale(error, fallback, locale); +export function messageRefreshErrorMessage(error: unknown, locale: UiLocale): string { + 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, _locale: UiLocale): string { + return unexpectedErrorFallback(error, fallback, 'command-palette'); } export function openPathActionErrorMessage( @@ -55,13 +55,15 @@ 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); + if (result.errorMessage) { + reportUnexpectedError('connection-test', result.errorMessage); + } + return 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 f6473dd7e7..cf48dfd59a 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -2875,7 +2875,7 @@ function AppShellContent({ {workHubEnabled && navSelection.section === 'sessions' && activeId ? (