From 0bda47ecb3358b5e70ad450c09b70b8bd33ca19c Mon Sep 17 00:00:00 2001 From: NekoPunch Date: Tue, 8 Sep 2026 12:52:02 -0700 Subject: [PATCH] fix(desktop): preserve failure codes across IPC Expected failures must survive both Electron IPC and contextBridge serialization so the renderer can present actionable localized copy. Return structured envelopes through those boundaries, unwrap them only in renderer-owned adapters, and keep attachment rejection presentation separate from Skill feedback. Runtime configuration guards use typed transition errors instead of localized prose. Generated-by: Claude Code Generated-by: OpenCode Generated-by: Codex --- apps/desktop/e2e-budget.json | 4 + .../e2e/expected-failure-feedback.spec.ts | 88 +++++++++++++ apps/desktop/renderer-architecture.json | 7 +- ...pp-shell-attachment-preflight-send.test.ts | 50 ++++++-- .../attachment-ingest-payload.test.ts | 6 +- .../attachment-ingest-resolve.test.ts | 32 +++-- .../__tests__/attachment-preflight.test.ts | 14 +-- .../expected-error-presentation.test.ts | 60 +++++---- .../__tests__/runtime-host-client-uds.test.ts | 17 ++- ...time-host-session-domains-ipc-main.test.ts | 25 +--- ...me-host-session-execution-ipc-main.test.ts | 34 +++++ .../runtime-host-workhub-ipc-main.test.ts | 49 ++++++++ .../src/main/__tests__/session-local.test.ts | 19 ++- .../session-settings-controller.test.ts | 2 + .../session-settings-services-adapter.test.ts | 21 +++- .../workbar-services-adapter.test.ts | 1 + ...ub-coordination-transcript-preload.test.ts | 19 ++- apps/desktop/src/main/attachment-ingest.ts | 32 ++--- .../runtime-host-session-catalog-ipc-main.ts | 44 +++++-- .../runtime-host-session-domains-ipc-main.ts | 24 ++-- ...runtime-host-session-execution-ipc-main.ts | 116 +++++++++--------- .../src/main/runtime-host-workhub-ipc-main.ts | 28 +++-- .../desktop/src/main/session-local-service.ts | 95 ++++++++------ .../src/preload/attachment-ingest-payload.ts | 8 +- apps/desktop/src/preload/bridge-contract.d.ts | 35 ++++-- apps/desktop/src/preload/preload.ts | 92 ++++++++++---- .../src/renderer/app-shell-chat-actions.ts | 21 +--- apps/desktop/src/renderer/app-shell.tsx | 4 +- .../src/renderer/attachment-preflight.ts | 19 +-- .../features/session-settings/ports.ts | 3 + .../use-session-setting-intent.ts | 2 + .../src/renderer/locales/shell-copy.ts | 43 +++++-- .../create-session-settings-services.ts | 24 +++- .../desktop/create-workbar-services.ts | 5 +- .../desktop/create-workhub-services.ts | 7 +- .../src/renderer/skill-invocation-feedback.ts | 39 +++++- .../src/shared/desktop-session-projection.ts | 10 ++ .../src/shared/workhub-conversation.d.ts | 6 + packages/core/src/attachments.ts | 7 +- .../src/__tests__/session-manager.test.ts | 10 +- packages/runtime/src/session-manager.ts | 30 ++++- 41 files changed, 803 insertions(+), 349 deletions(-) create mode 100644 apps/desktop/e2e/expected-failure-feedback.spec.ts create mode 100644 apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index 773aef08f9..d56aab798f 100644 --- a/apps/desktop/e2e-budget.json +++ b/apps/desktop/e2e-budget.json @@ -9,6 +9,10 @@ "tests": 1, "electron": "the folder reference has to survive a renderer reload and still agree with the Host's session record" }, + "expected-failure-feedback.spec.ts": { + "tests": 2, + "electron": "WorkHub attachment rejection crosses a native file dialog, preload and main; Session settings and Plan failures must preserve their codes through a real contextBridge IPC round trip" + }, "new-task-reload.spec.ts": { "tests": 2, "electron": "renderer reload must preserve an explicit new task and rebuild archived-only Host history as an empty, usable new-task surface" diff --git a/apps/desktop/e2e/expected-failure-feedback.spec.ts b/apps/desktop/e2e/expected-failure-feedback.spec.ts new file mode 100644 index 0000000000..4129125964 --- /dev/null +++ b/apps/desktop/e2e/expected-failure-feedback.spec.ts @@ -0,0 +1,88 @@ +/* + * 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 { truncate, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { FAKE_HOLD_OPEN_PROMPT } from '@maka/runtime/test-only/fake-backend'; +import { MAX_ATTACHMENT_BYTES } from '@maka/core/attachments'; +import { + awaitSendReady, + COMPOSER_INPUT, + expect, + getWorkHubPage, + test, + withE2eWindow, +} from './fixtures'; + +test('WorkHub shows the main-side attachment rejection reason', async ({}, testInfo) => { + await withE2eWindow( + { seed: true, readinessSelector: COMPOSER_INPUT, locale: 'zh-CN', showWindow: true }, + async (page, { app, userDataDir }) => { + const attachmentPath = path.join(userDataDir, 'grew-after-selection.txt'); + await writeFile(attachmentPath, 'small'); + await app.evaluate(({ dialog }, selectedPath) => { + dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [selectedPath] }); + }, attachmentPath); + await page.evaluate(() => window.maka.settings.updateClient({ workHub: { enabled: true } })); + const workHub = await getWorkHubPage(app); + const addMenu = workHub.locator('.maka-composer-plus-menu button').first(); + await addMenu.click(); + await workHub.getByRole('menuitem', { name: '添加文件', exact: true }).click(); + await expect(workHub.getByText('grew-after-selection.txt', { exact: true })).toBeVisible(); + + await truncate(attachmentPath, MAX_ATTACHMENT_BYTES + 1); + await workHub.locator(COMPOSER_INPUT).fill('验证附件错误提示'); + await workHub.locator(COMPOSER_INPUT).press('Enter'); + await expect(workHub.getByText('发送失败', { exact: true })).toBeVisible(); + const screenshotPath = testInfo.outputPath('workhub-attachment-rejection.png'); + await workHub.screenshot({ path: screenshotPath, animations: 'disabled' }); + await testInfo.attach('WorkHub attachment rejection', { + path: screenshotPath, + contentType: 'image/png', + }); + await expect(workHub.getByText('单个附件超出大小限制。', { exact: true })).toBeVisible(); + }, + ); +}); + +test('setting and Plan failures retain their codes through Electron', async ({ window: page }) => { + const composer = page.locator(COMPOSER_INPUT); + await composer.fill(FAKE_HOLD_OPEN_PROMPT); + await awaitSendReady(page); + await composer.press('Enter'); + await expect.poll(async () => { + const sessions = await page.evaluate(() => window.maka.sessions.list()); + return sessions.some(({ status }) => status === 'running'); + }).toBe(true); + const sessionId = await page.evaluate(async () => { + const sessions = await window.maka.sessions.list(); + return sessions.find(({ status }) => status === 'running')!.id; + }); + + const result = await page.evaluate(async (id) => ({ + setting: await window.maka.sessions.setPermissionMode(id, 'explore'), + plan: await window.maka.sessions.abandonPlanProposal(id, 'missing-proposal'), + }), sessionId); + expect(result.setting).toEqual({ ok: false, code: 'session_busy' }); + expect(result.plan).toMatchObject({ + ok: false, + error: { code: 'session_busy' }, + }); + await page.evaluate((id) => window.maka.sessions.stop(id), sessionId); +}); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index ddca25f7df..d15396c9eb 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -322,7 +322,7 @@ "@maka/ui": 1 }, "importSpecifiers": 10, - "nonTriviaTokens": 3636 + "nonTriviaTokens": 3618 }, "src/renderer/app-shell-chrome-actions.tsx": { "importDeclarations": 4, @@ -707,7 +707,6 @@ "window.maka.diagnostics.takePreviousMainProcessInterruption": 1, "window.maka.notifications.runEnded": 1, "window.maka.onboarding.setMilestone": 1, - "window.maka.sessions.abandonPlanProposal": 1, "window.maka.sessions.compact": 1, "window.maka.sessions.getPlanState": 1, "window.maka.sessions.listActiveInteractions": 1, @@ -715,7 +714,6 @@ "window.maka.sessions.promoteQueueEntry": 1, "window.maka.sessions.reorderQueueEntries": 1, "window.maka.sessions.retractQueueEntry": 1, - "window.maka.sessions.setCollaborationMode": 1, "window.maka.sessions.subscribeActiveInteractions": 1, "window.maka.sessions.updateQueueEntry": 1, "window.maka.settings.getClient": 1, @@ -856,7 +854,7 @@ "react": 1 }, "importSpecifiers": 104, - "nonTriviaTokens": 13394 + "nonTriviaTokens": 13386 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, @@ -1701,6 +1699,7 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { + "@maka/core/attachments": 1, "@maka/core/redaction": 1, "@maka/core/ui-locale": 1 } diff --git a/apps/desktop/src/main/__tests__/app-shell-attachment-preflight-send.test.ts b/apps/desktop/src/main/__tests__/app-shell-attachment-preflight-send.test.ts index b8ca804cdc..2b77b9d3ae 100644 --- a/apps/desktop/src/main/__tests__/app-shell-attachment-preflight-send.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-attachment-preflight-send.test.ts @@ -17,16 +17,6 @@ * under the License. */ -/** - * Review regression on #4457: the new-task send path runs the renderer-side - * attachment preflight BEFORE `newTasks.create`, so the main-side token - * validation never sees an over-limit request. The preflight must reject with - * the same stable `attachment_ingest:` tokens main rejects with, so the - * send catch maps the real reason through the locale catalog instead of the - * generic "try again later" fallback (retrying nine attachments can never - * succeed) — and an expected rejection must not log an unexpected diagnostic. - */ - import { MAX_ATTACHMENT_COUNT } from '@maka/core/attachments'; import { strict as assert } from 'node:assert'; import { test } from 'node:test'; @@ -96,4 +86,42 @@ test('a nine-attachment new-task send shows the count reason, creates no session 0, 'an expected preflight rejection must not land the unexpected-error diagnostic', ); -}); \ No newline at end of file +}); + +test('a main-side attachment rejection refuses the send instead of leaving it unreconciled', async (context) => { + const errorLog = context.mock.method(console, 'error', () => undefined); + const created: string[] = []; + const toasts: Array<{ title: string; description?: string }> = []; + const restoreWindow = installWindow({ + newTasks: { + create: async () => { + created.push('session-1'); + return { id: 'session-1' }; + }, + }, + sessions: { + submitMessage: async () => ({ ok: false, reason: 'attachment_blocked', code: 'item_too_large' }), + }, + }); + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + uiLocale: 'zh-CN', + toastApi: { + error: (title, description) => { + toasts.push({ title, description }); + }, + info: () => undefined, + }, + }); + const accepted = await actions.send('hello', [fileAttachment(10, 0)]); + assert.equal(accepted, false, 'a blocked attachment is a definitive refusal, not an unknown outcome'); + assert.deepEqual(toasts, [{ + title: getShellCopy('zh-CN').chatActions.sendFailedTitle, + description: getShellCopy('zh-CN').sessionSettingsActions.attachmentIngestBlocked.item_too_large, + }]); + } finally { + restoreWindow(); + } + assert.equal(errorLog.mock.callCount(), 0, 'an expected attachment rejection logs no diagnostic'); +}); 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 9d706bfe17..fb0547ddc5 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), /attachment_ingest:count_limit/); + await assert.rejects(encodeIngestItems(items as never), { code: 'count_limit' }); }); 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 }]), /attachment_ingest:item_too_large/); + await assert.rejects(encodeIngestItems([{ file: bigFile }]), { code: 'item_too_large' }); 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:items_invalid/, + { code: 'items_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 c050c5aa09..6590853eb9 100644 --- a/apps/desktop/src/main/__tests__/attachment-ingest-resolve.test.ts +++ b/apps/desktop/src/main/__tests__/attachment-ingest-resolve.test.ts @@ -45,7 +45,7 @@ describe('resolveIngestItems (pre-read validation)', () => { approvals, stat: async () => (statCalls++, { size: 1 }), }), - /attachment_ingest:count_limit/, + { code: 'count_limit' }, ); assert.equal(statCalls, 0); }); @@ -61,7 +61,7 @@ describe('resolveIngestItems (pre-read validation)', () => { approvals, stat: async () => (statCalls++, { size: 1 }), }), - /attachment_ingest:(source_expired|items_invalid)/, + { code: /^(source_expired|items_invalid)$/ }, ); assert.equal(statCalls, 0); }); @@ -78,7 +78,7 @@ describe('resolveIngestItems (pre-read validation)', () => { approvals, stat: async () => (statCalls++, { size: 1 }), }), - /attachment_ingest:(source_expired|items_invalid)/, + { code: /^(source_expired|items_invalid)$/ }, ); assert.equal(statCalls, 0); }); @@ -96,7 +96,7 @@ describe('resolveIngestItems (pre-read validation)', () => { stat: async () => (statCalls++, { size: 200 }), maxBytes: 100, }), - /attachment_ingest:item_too_large/, + { code: 'item_too_large' }, ); assert.equal(statCalls, 1); }); @@ -121,7 +121,7 @@ describe('resolveIngestItems (pre-read validation)', () => { stat: async () => (statCalls++, { size: 1 }), maxBytes: 100, }), - /attachment_ingest:item_too_large/, + { code: 'item_too_large' }, ); assert.equal(statCalls, 0); assert.equal(decodeCalls, 0, 'must reject by base64 string length before Buffer.from'); @@ -149,7 +149,7 @@ describe('resolveIngestItems (pre-read validation)', () => { approvals, stat: async () => ({ size: 10 }), }), - /attachment_ingest:(source_expired|items_invalid)/, + { code: /^(source_expired|items_invalid)$/ }, ); }); @@ -165,11 +165,17 @@ describe('resolveIngestItems (pre-read validation)', () => { for (const item of items) assert.ok(approvals.peekApproval(1, item.approvalId)); const competing = await prepareIngestItems({ ...input, items: [items[1]] }); assert.equal(competing.commit(() => 'admitted'), 'admitted'); - assert.throws(() => plan.commit(() => assert.fail('must not admit an invalid plan')), /attachment_ingest:source_expired/); + assert.throws( + () => plan.commit(() => assert.fail('must not admit an invalid plan')), + { code: 'source_expired' }, + ); assert.ok(approvals.peekApproval(1, items[0]!.approvalId), 'a lost race must not burn the other approval'); const remaining = await prepareIngestItems({ ...input, items: [items[0]] }); approvals.clearSender(1); - assert.throws(() => remaining.commit(() => assert.fail('must not admit after sender teardown')), /attachment_ingest:source_expired/); + assert.throws( + () => remaining.commit(() => assert.fail('must not admit after sender teardown')), + { code: 'source_expired' }, + ); }); test('resolves a mix of approved paths and blobs into ingest files', async () => { @@ -204,7 +210,7 @@ describe('resolveIngestItems (pre-read validation)', () => { approvals, stat: async () => (statCalls++, { size: 10 }), }), - /attachment_ingest:items_invalid/, + { code: 'items_invalid' }, ); assert.notEqual( approvals.consumeApproval(1, issued.approvalId), @@ -227,7 +233,7 @@ describe('resolveIngestItems (pre-read validation)', () => { approvals, stat: async () => (statCalls++, { size: 10 }), }), - /attachment_ingest:duplicate_source/, + { code: 'duplicate_source' }, ); assert.notEqual( approvals.consumeApproval(1, issued.approvalId), @@ -246,7 +252,7 @@ describe('resolveIngestItems (pre-read validation)', () => { approvals, stat: async () => ({ size: 1 }), }), - /attachment_ingest:items_invalid/, + { code: 'items_invalid' }, ); await assert.rejects( () => @@ -256,7 +262,7 @@ describe('resolveIngestItems (pre-read validation)', () => { approvals, stat: async () => ({ size: 1 }), }), - /attachment_ingest:items_invalid/, + { code: 'items_invalid' }, ); }); }); @@ -440,7 +446,7 @@ describe('resolveAttachmentRefs', () => { throw new Error('snapshot must not run'); }, }), - /attachment_ingest:item_too_large/, + { code: 'item_too_large' }, ); assert.equal(snapshots, 0); } finally { diff --git a/apps/desktop/src/main/__tests__/attachment-preflight.test.ts b/apps/desktop/src/main/__tests__/attachment-preflight.test.ts index f928f31c55..0e8d7f525d 100644 --- a/apps/desktop/src/main/__tests__/attachment-preflight.test.ts +++ b/apps/desktop/src/main/__tests__/attachment-preflight.test.ts @@ -28,9 +28,7 @@ describe('attachment preflight (before session create)', () => { size: 100, source: { type: 'file' as const, file: { size: 100 } }, })); - assert.throws(() => preflightAttachmentItems(items), { - message: 'attachment_ingest:count_limit', - }); + assert.throws(() => preflightAttachmentItems(items), { code: 'count_limit' }); }); test('rejects an oversized File so no empty session is created', () => { @@ -39,7 +37,7 @@ describe('attachment preflight (before session create)', () => { preflightAttachmentItems([ { size: MAX_ATTACHMENT_BYTES + 1, source: { type: 'file', file: { size: MAX_ATTACHMENT_BYTES + 1 } } }, ]), - { message: 'attachment_ingest:item_too_large' }, + { code: 'item_too_large' }, ); }); @@ -49,7 +47,7 @@ describe('attachment preflight (before session create)', () => { preflightAttachmentItems([ { size: MAX_ATTACHMENT_BYTES + 1, source: { type: 'approval', approvalId: 'a1' } }, ]), - { message: 'attachment_ingest:item_too_large' }, + { code: 'item_too_large' }, ); }); @@ -58,9 +56,7 @@ describe('attachment preflight (before session create)', () => { { size: 10, source: { type: 'approval' as const, approvalId: 'dup' } }, { size: 10, source: { type: 'approval' as const, approvalId: 'dup' } }, ]; - assert.throws(() => preflightAttachmentItems(duplicate), { - message: 'attachment_ingest:duplicate_source', - }); + assert.throws(() => preflightAttachmentItems(duplicate), { code: 'duplicate_source' }); }); test('passes approval tokens and files under the cap', () => { @@ -71,4 +67,4 @@ describe('attachment preflight (before session create)', () => { ]), ); }); -}); \ No newline at end of file +}); diff --git a/apps/desktop/src/main/__tests__/expected-error-presentation.test.ts b/apps/desktop/src/main/__tests__/expected-error-presentation.test.ts index db89623789..76a8e9e855 100644 --- a/apps/desktop/src/main/__tests__/expected-error-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/expected-error-presentation.test.ts @@ -31,7 +31,11 @@ import { messageRefreshErrorMessage, openPathActionErrorMessage, } from '../../renderer/app-shell-copy.js'; -import { getShellCopy, localizedShellErrorMessage } from '../../renderer/locales/shell-copy.js'; +import { + getShellCopy, + localizedShellErrorMessage, + sessionSettingFailureCopy, +} 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) => { @@ -55,26 +59,16 @@ test('routes Work Board codes through the shared presenter per locale', (context ); }); -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 个附件。'); +test('session setting failures map expected update codes per locale', () => { + const blocked = new ExpectedOperationError('session_busy'); assert.equal( - localizedShellErrorMessage(blocked, 'fallback', 'en'), - 'At most 8 attachments per message.', + sessionSettingFailureCopy('zh-CN', 'permission', blocked).description, + '当前任务正在运行或有交互待处理,等结束后再改设置。', + ); + assert.equal( + sessionSettingFailureCopy('en', 'plan', blocked).description, + 'A task is running or waiting on you. Change this setting after it settles.', ); -}); - -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) => { @@ -110,16 +104,14 @@ test('maps plan control envelopes per locale at the panel', () => { } }); - -test('unknown and inherited reason tokens retain the caller fallback', (context) => { +test('unexpected setting failures keep 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'); - } - } + const copy = getShellCopy(locale).sessionSettingsActions; + assert.equal( + sessionSettingFailureCopy(locale, 'permission', new Error('boom')).description, + copy.permissionFallback, + ); } }); @@ -215,3 +207,17 @@ test('a classified failure never reaches the diagnostics channel', (context) => reportUnexpectedError('remote-directory:list', new Error('an opaque backend fault')); assert.equal(errors.mock.callCount(), 1); }); + +test('a renderer-owned plan error maps to the actionable failure copy', (context) => { + const errors = context.mock.method(console, 'error', () => undefined); + const copy = getShellCopy('zh-CN'); + assert.equal( + sessionSettingFailureCopy('zh-CN', 'plan', new ExpectedOperationError('operation_conflict')).description, + copy.sessionSettingsActions.updateFailures.operation_conflict, + ); + assert.equal(errors.mock.callCount(), 0); + assert.equal( + sessionSettingFailureCopy('zh-CN', 'plan', new Error("Error invoking remote method 'plan-mode:abandon': Error: operation_conflict")).description, + copy.app.planModeFallback, + ); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts index faf74c8fca..d0b67b69b2 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts @@ -309,12 +309,17 @@ test('drives the renderer Session catalog facade through real UDS framing', asyn /Invalid Session list filter/, ); } - assert.equal( - (await ipc.invoke('sessions:setPermissionMode', 'session-ipc', 'bypass') as { - permissionMode: string; - }).permissionMode, - 'bypass', - ); + const modeUpdate = await ipc.invoke('sessions:setPermissionMode', 'session-ipc', 'bypass'); + if (typeof modeUpdate !== 'object' || modeUpdate === null || !('ok' in modeUpdate)) { + throw new Error('sessions:setPermissionMode did not return an update envelope'); + } + if (!modeUpdate.ok) throw new Error('Expected the committed mode update envelope'); + if (!('session' in modeUpdate) || typeof modeUpdate.session !== 'object') { + throw new Error('Committed envelope missing session'); + } + const updatedSession = modeUpdate.session as { permissionMode: string; revision: number }; + assert.equal(updatedSession.permissionMode, 'bypass'); + assert.equal(updatedSession.revision, 2); await ipc.invoke('sessions:archive', 'session-ipc'); assert.equal((await ipc.invoke('sessions:list') as Array<{ isArchived: boolean }>)[0]?.isArchived, true); // A purge sweep asks for the task it saw archived. Restored under it, the 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 c23cd435b1..ba5efe29c3 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 @@ -1492,7 +1492,6 @@ test('plan control channels rethrow failures outside the expected plan-control s (error: unknown) => error === boom, ); }); - test('plan control channels return the Host error code across the IPC boundary', async () => { const cases = [ { @@ -1501,6 +1500,12 @@ test('plan control channels return the Host error code across the IPC boundary', 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: [ @@ -1546,21 +1551,3 @@ test('plan control channels return the Host error code across the IPC boundary', assert.deepEqual(changed, [], `${scenario.channel} must not report a mode change`); } }); - -test('the plan proposal exit channel rejects instead of returning an envelope', async () => { - const ipc = ipcHarness(); - const changed: string[] = []; - const cause = new RuntimeHostOperationError('plan.control', 'operation_conflict', 'Host refused the plan control'); - registerDomainsIpc({ - client: domainClient({ - getPlanState: async () => emptyPlanSessionState('session-1'), - controlPlan: async () => { - throw cause; - }, - }), - emitModeChanged: (sessionId) => changed.push(sessionId), - newId: () => 'fixed-id', - }, ipc); - await assert.rejects(() => ipc.invoke('plan-mode:abandon', 'session-1', 'proposal-1'), (error) => error === cause); - assert.deepEqual(changed, []); -}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index fc8683793a..b4301008f6 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -2200,6 +2200,40 @@ test('does not let an admitted Stop interrupt a replacement Turn', async () => { await observer.close(); }); +test('returns the attachment_blocked envelope when an approved source has expired', async () => { + const ipc = ipcHarness(); + registerExecutionIpc( + { + client: executionClient({ + getSession: async () => session(), + submitMessage: async () => { + throw new Error("A blocked send must never reach the Host"); + }, + }), + observer: unusedObserver(), + attachmentApprovals: createAttachmentApprovalRegistry(), + emitSessionsChanged() {}, + stat: async () => { + throw new Error("an expired approval must not touch the filesystem"); + }, + beforeStop() {}, + newId: () => "turn-1", + }, + ipc, + ); + + const result = await ipc.invoke("sessions:send", "session-1", { + type: "send", + text: "expired attachment", + attachmentItems: [{ + approvalId: "expired", + name: "expired.txt", + mimeType: "text/plain", + }], + }); + assert.deepEqual(result, { ok: false, reason: "attachment_blocked", code: "source_expired" }); +}); + type ExecutionClient = RuntimeHostSessionExecutionIpcDeps["client"]; function executionClient(overrides: Partial): ExecutionClient { diff --git a/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts new file mode 100644 index 0000000000..f13a98c8c1 --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts @@ -0,0 +1,49 @@ +/* + * 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 type { IpcHandler } from '../ipc-reconnect-policy.js'; +import { registerRuntimeHostWorkHubIpc } from '../runtime-host-workhub-ipc-main.js'; + +test('returns a structured WorkHub attachment rejection across IPC', async () => { + const handlers = new Map(); + registerRuntimeHostWorkHubIpc( + {} as Parameters[0], + { + handle(channel, handler) { + handlers.set(channel, handler); + }, + }, + { + attachmentIngest: { + approvals: {} as never, + stat: async () => ({ size: 0 }), + }, + }, + ); + + const prepareAttachments = handlers.get('workhub:prepareAttachments'); + assert.ok(prepareAttachments); + const result = await prepareAttachments( + { sender: { id: 7 } } as Parameters[0], + Array.from({ length: 9 }, () => ({})), + ); + assert.deepEqual(result, { ok: false, code: 'count_limit' }); +}); diff --git a/apps/desktop/src/main/__tests__/session-local.test.ts b/apps/desktop/src/main/__tests__/session-local.test.ts index 0f54f9df22..3e5e33ecd2 100644 --- a/apps/desktop/src/main/__tests__/session-local.test.ts +++ b/apps/desktop/src/main/__tests__/session-local.test.ts @@ -706,16 +706,15 @@ test('local submit preserves picked-file approvals until durable admission succe largeFiles.push({ path: imagePath, name, size: 33 * 1024 * 1024 }); } const largePicked = approvals.issueApprovals(7, largeFiles); - await assert.rejects( - () => - submit( - { sender: { id: 7 } } as IpcMainInvokeEvent, - target.scope, - 'session-1', - 'current_turn', - { ...draft, messageId: 'too-large', attachmentItems: largePicked }, - ), - /attachment_ingest:total_size_exceeded/, + assert.deepEqual( + await submit( + { sender: { id: 7 } } as IpcMainInvokeEvent, + target.scope, + 'session-1', + 'current_turn', + { ...draft, messageId: 'too-large', attachmentItems: largePicked }, + ), + { ok: false, reason: 'attachment_blocked', code: 'total_size_exceeded' }, ); assert.equal(resizeCalls, 0); assert.equal(store.get('authority', 'too-large'), undefined); diff --git a/apps/desktop/src/main/__tests__/session-settings-controller.test.ts b/apps/desktop/src/main/__tests__/session-settings-controller.test.ts index bb9f477fe7..cc759fb8a5 100644 --- a/apps/desktop/src/main/__tests__/session-settings-controller.test.ts +++ b/apps/desktop/src/main/__tests__/session-settings-controller.test.ts @@ -543,6 +543,8 @@ function createServices( setModelConfiguration: async () => ({} as DesktopSessionSummary), setPermissionMode: async () => ({} as DesktopSessionSummary), setOrchestrationMode: async () => ({} as DesktopSessionSummary), + setCollaborationMode: async () => ({} as DesktopSessionSummary), + abandonPlanProposal: async () => {}, ...overrides, }; } diff --git a/apps/desktop/src/main/__tests__/session-settings-services-adapter.test.ts b/apps/desktop/src/main/__tests__/session-settings-services-adapter.test.ts index 2eba5950fb..cf6353b7aa 100644 --- a/apps/desktop/src/main/__tests__/session-settings-services-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/session-settings-services-adapter.test.ts @@ -27,7 +27,8 @@ test('maps session setting services to the existing compound Desktop bridge', as const sessions = new Proxy({}, { get: (_target, property) => (...args: unknown[]) => { calls.push({ name: String(property), args }); - return Promise.resolve({}); + if (property === 'abandonPlanProposal') return Promise.resolve({ ok: true, value: {} }); + return Promise.resolve({ ok: true, session: {} }); }, }); const services = createDesktopSessionSettingsServices({ @@ -42,6 +43,7 @@ test('maps session setting services to the existing compound Desktop bridge', as }); await services.setPermissionMode('session-1', 'bypass'); await services.setOrchestrationMode('session-1', 'swarm'); + await services.abandonPlanProposal('session-1', 'proposal-1'); assert.deepEqual(calls, [ { @@ -55,5 +57,22 @@ test('maps session setting services to the existing compound Desktop bridge', as }, { name: 'setPermissionMode', args: ['session-1', 'bypass'] }, { name: 'setOrchestrationMode', args: ['session-1', 'swarm'] }, + { name: 'abandonPlanProposal', args: ['session-1', 'proposal-1'] }, ]); }); + +test('unwraps a plan failure only after it reaches the renderer adapter', async () => { + const services = createDesktopSessionSettingsServices({ + sessions: { + abandonPlanProposal: async () => ({ + ok: false, + error: { code: 'operation_conflict', message: 'Host refused the transition' }, + }), + }, + } as unknown as MakaBridge); + + await assert.rejects( + () => services.abandonPlanProposal('session-1', 'proposal-1'), + { name: 'ExpectedOperationError', message: 'operation_conflict' }, + ); +}); diff --git a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts index 30e0665fef..f9f724a310 100644 --- a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts @@ -41,6 +41,7 @@ function createBridgeRecorder(): { ]); // Adapters that reshape a bridge answer need one to reshape. const answers = new Map([ + ['sessions.setPermissionMode', { ok: true, session: {} }], [ 'sessions.submitMessage', { diff --git a/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts b/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts index 4a06cc70c0..2f6559df9c 100644 --- a/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts @@ -31,7 +31,9 @@ import type { DesktopTranscriptBatch, DesktopTranscriptRangeRequest } from '../. import { createDesktopWorkHubServices } from '../../renderer/platform/desktop/create-workhub-services.js'; import type { WorkHubTranscriptSnapshot } from '../../renderer/features/workhub/index.js'; import { desktopSessionKey } from '../../shared/runtime-host-identity.js'; +import type { WorkHubPrepareAttachmentsResult } from '../../shared/workhub-conversation.js'; import { encodeDesktopTranscriptPage, encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; +import { AttachmentIngestBlockedError } from '@maka/core/attachments'; import type { AttachmentRef } from '@maka/core/events'; import { MESSAGE_QUEUE_MAX_ENTRIES } from '@maka/runtime-host/protocol'; @@ -46,6 +48,10 @@ test('WorkHub upload references round-trip through idle answers, both queue mode kind: 'doc', name: 'brief.txt', mimeType: 'text/plain', bytes: 5, ref: { kind: 'session_file', sessionId: nativeSessionId, relativePath: 'brief.txt' }, }; + let preparationResult: WorkHubPrepareAttachmentsResult = { + ok: true, + attachments: [uploaded], + }; const sent: Array<{ channel: string; attachments: AttachmentRef[] }> = []; let bridge!: MakaBridge; const bundle = await build({ @@ -64,7 +70,7 @@ test('WorkHub upload references round-trip through idle answers, both queue mode assert.equal((args[0] as typeof owner).hostId, owner.hostId); if (channel === 'workhub:prepareAttachments') { assert.deepEqual(structuredClone(args[1]), [{ name: 'brief.txt', mimeType: 'text/plain', base64: 'aGVsbG8=' }]); - return [uploaded]; + return preparationResult; } if (channel === 'workhub:answer') { const input = args[1] as { attachments: AttachmentRef[]; turnId: string }; @@ -103,6 +109,17 @@ test('WorkHub upload references round-trip through idle answers, both queue mode } assert.deepEqual(structuredClone(sent.map(({ attachments }) => attachments)), [[uploaded], [uploaded], [uploaded]]); assert.equal((await services.readAttachmentBytes(sessionId, 'brief.txt')).ok, true); + preparationResult = { ok: false, code: 'item_too_large' }; + await assert.rejects( + services.prepareAttachments(sessionId, [ + { file: new File(['hello'], 'brief.txt', { type: 'text/plain' }) }, + ]), + (error: unknown) => { + assert.ok(error instanceof AttachmentIngestBlockedError); + assert.equal(error.code, 'item_too_large'); + return true; + }, + ); const foreign = [{ ...uploaded, ref: { ...uploaded.ref, kind: 'session_file' as const, sessionId: desktopSessionKey({ hostId: 'foreign-host', sessionId: nativeSessionId }), relativePath: 'brief.txt' } }]; await assert.rejects(services.answer(sessionId, { turnId: 'foreign', text: 'read this', attachments: foreign }), /another Host or Session/); await assert.rejects(services.enqueueMessage(sessionId, 'foreign', 'read this', foreign, 'next_turn'), /another Host or Session/); diff --git a/apps/desktop/src/main/attachment-ingest.ts b/apps/desktop/src/main/attachment-ingest.ts index 6bffd28d20..bbac179da8 100644 --- a/apps/desktop/src/main/attachment-ingest.ts +++ b/apps/desktop/src/main/attachment-ingest.ts @@ -21,7 +21,7 @@ import { Buffer } from 'node:buffer'; import { open } from 'node:fs/promises'; import { basename } from 'node:path'; import { - attachmentIngestBlocked, + AttachmentIngestBlockedError, attachmentKindFromMimeType, MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_COUNT, @@ -68,7 +68,7 @@ export async function resolveAttachmentRefs(input: { ? await readFileCapped(file.path, Math.min(maxBytes, maxTotalBytes - readBytes)) : file.content; readBytes += bytes.byteLength; - if (readBytes > maxTotalBytes) throw attachmentIngestBlocked('total_size_exceeded'); + if (readBytes > maxTotalBytes) throw new AttachmentIngestBlockedError('total_size_exceeded'); let mimeType = resolveAttachmentMimeType(bytes, file.mimeType, name); const kind = attachmentKindFromMimeType(mimeType, name); @@ -77,7 +77,7 @@ export async function resolveAttachmentRefs(input: { mimeType = sniffAttachmentMimeType(bytes) ?? mimeType; } snapshotBytes += bytes.byteLength; - if (snapshotBytes > maxTotalBytes) throw attachmentIngestBlocked('total_size_exceeded'); + if (snapshotBytes > maxTotalBytes) throw new AttachmentIngestBlockedError('total_size_exceeded'); const artifactKind: ArtifactKind = kind === 'image' ? 'image' : kind === 'pdf' ? 'pdf' : 'file'; refs.push( @@ -165,7 +165,7 @@ export async function readFileCapped(path: string, maxBytes: number): Promise maxBytes) throw attachmentIngestBlocked('item_too_large'); + if (bytesRead > maxBytes) throw new AttachmentIngestBlockedError('item_too_large'); return buf.subarray(0, bytesRead); } finally { await fh.close(); @@ -206,24 +206,24 @@ export async function prepareIngestItems(input: { const maxAttachments = input.maxAttachments ?? MAX_ATTACHMENT_COUNT; const maxBytes = input.maxBytes ?? MAX_ATTACHMENT_BYTES; let remainingBytes = input.maxTotalBytes ?? Infinity; - if (!Array.isArray(input.items)) throw attachmentIngestBlocked('items_invalid'); - if (input.items.length > maxAttachments) throw attachmentIngestBlocked('count_limit'); + if (!Array.isArray(input.items)) throw new AttachmentIngestBlockedError('items_invalid'); + if (input.items.length > maxAttachments) throw new AttachmentIngestBlockedError('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 attachmentIngestBlocked('items_invalid'); + if (!item || typeof item !== 'object') throw new AttachmentIngestBlockedError('items_invalid'); const record = item as Record; if (typeof record.approvalId === 'string' && typeof record.name === 'string') { - if (seenApprovalIds.has(record.approvalId)) throw attachmentIngestBlocked('duplicate_source'); + if (seenApprovalIds.has(record.approvalId)) throw new AttachmentIngestBlockedError('duplicate_source'); seenApprovalIds.add(record.approvalId); const approved = input.approvals.peekApproval(input.senderId, record.approvalId); - if (!approved) throw attachmentIngestBlocked('source_expired'); + if (!approved) throw new AttachmentIngestBlockedError('source_expired'); const statResult = await input.stat(approved.path); - if (statResult.size > maxBytes) throw attachmentIngestBlocked('item_too_large'); - if (statResult.size > remainingBytes) throw attachmentIngestBlocked('total_size_exceeded'); + if (statResult.size > maxBytes) throw new AttachmentIngestBlockedError('item_too_large'); + if (statResult.size > remainingBytes) throw new AttachmentIngestBlockedError('total_size_exceeded'); remainingBytes -= statResult.size; const mimeType = pickMimeType(record.mimeType, approved.mimeType); planned.push({ path: approved.path, ...(mimeType ? { mimeType } : {}), size: statResult.size }); @@ -235,17 +235,17 @@ export async function prepareIngestItems(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 attachmentIngestBlocked('item_too_large'); + if (record.base64.length > maxBase64Len) throw new AttachmentIngestBlockedError('item_too_large'); if (Buffer.byteLength(record.base64, 'base64') > remainingBytes) - throw attachmentIngestBlocked('total_size_exceeded'); + throw new AttachmentIngestBlockedError('total_size_exceeded'); const content = Buffer.from(record.base64, 'base64'); - if (content.byteLength > maxBytes) throw attachmentIngestBlocked('item_too_large'); + if (content.byteLength > maxBytes) throw new AttachmentIngestBlockedError('item_too_large'); remainingBytes -= content.byteLength; 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 attachmentIngestBlocked('items_invalid'); + throw new AttachmentIngestBlockedError('items_invalid'); } return { files: planned, @@ -254,7 +254,7 @@ export async function prepareIngestItems(input: { // teardown or expiry during preparation must not burn another token. for (const id of approvalIds) { if (!input.approvals.peekApproval(input.senderId, id)) - throw attachmentIngestBlocked('source_expired'); + throw new AttachmentIngestBlockedError('source_expired'); } const result = admit(); for (const id of approvalIds) input.approvals.consumeApproval(input.senderId, id); diff --git a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts index d194230620..a539f2aaef 100644 --- a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts @@ -24,17 +24,22 @@ import { isPermissionMode } from '@maka/core/permission'; import { isThinkingLevel, type ThinkingLevel } from '@maka/core/model-thinking'; import { type CreateSessionRequestInput, type SessionListFilter } from '@maka/core/runtime-inputs'; import { type SessionChangedEvent, type SessionChangedReason, type SessionCatalogSummary } from '@maka/core/session'; -import { projectSessionCatalogSummary } from '@maka/runtime-host/client'; +import { RuntimeHostOperationError, projectSessionCatalogSummary } from '@maka/runtime-host/client'; import type { SessionCatalogProjection, SessionCreateInput, WorkspaceTarget, SessionModelTarget, } from '@maka/runtime-host/protocol'; -import { resolveCreateSessionRequest } from './create-session-input.js'; import type { - DesktopRuntimeHostClient, - DesktopSessionConfigurationPatch, + DesktopSessionUpdateFailureCode, + DesktopSessionUpdateResult, +} from '../shared/desktop-session-projection.js'; +import { resolveCreateSessionRequest } from './create-session-input.js'; +import { + type DesktopRuntimeHostClient, + DesktopRuntimeHostClientError, + type DesktopSessionConfigurationPatch, } from './runtime-host-client.js'; import { requestsRevisionFamily, @@ -256,10 +261,35 @@ async function updateConfiguration( patch: DesktopSessionConfigurationPatch, reason: SessionChangedReason, extra?: Pick, -): Promise { - const session = await deps.client.updateSessionConfiguration(sessionId, patch); +): Promise> { + let session: SessionCatalogProjection; + try { + session = await deps.client.updateSessionConfiguration(sessionId, patch); + } catch (error) { + const code = updateFailureCode(error); + if (code) return { ok: false, code }; + throw error; + } deps.emitSessionsChanged(reason, sessionId, extra); - return toDesktopHostSessionSummary(session); + return { ok: true, session: toDesktopHostSessionSummary(session) }; +} + +const EXPECTED_UPDATE_FAILURES = [ + 'session_busy', + 'operation_conflict', + 'operation_unavailable', + 'not_found', +] as const; + +function updateFailureCode(error: unknown): DesktopSessionUpdateFailureCode | undefined { + if (error instanceof RuntimeHostOperationError) { + return EXPECTED_UPDATE_FAILURES.find((code) => code === error.code); + } + if (error instanceof DesktopRuntimeHostClientError) { + if (error.code === 'revision_conflict') return 'operation_conflict'; + if (error.code === 'session_not_found') return 'not_found'; + } + return undefined; } function normalizeParentSessionFilter(value: unknown): string | undefined { 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 ca26e2eca8..8452baa944 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 @@ -215,18 +215,22 @@ export function registerRuntimeHostSessionDomainsIpc( ); ipcMain.handle( 'plan-mode:abandon', - // The app-shell exit path is the only caller and is token-frozen, so this - // channel keeps its throwing shape: an envelope here would reach no reader. - async (_event, sessionId: unknown, proposalId: unknown): Promise => { + 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( diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index f11cdc238e..5b156b094c 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -19,7 +19,11 @@ import { randomUUID } from "node:crypto"; import type { IpcMainInvokeEvent } from "electron"; -import { MAX_ATTACHMENT_COUNT } from '@maka/core/attachments'; +import { + AttachmentIngestBlockedError, + MAX_ATTACHMENT_COUNT, + type AttachmentIngestBlockedCode, +} from '@maka/core/attachments'; import { isSideConversationSession } from '@maka/core/side-conversation'; import { RuntimeHostOperationError, @@ -183,6 +187,44 @@ export interface RuntimeHostSessionExecutionIpcDeps { newId?: () => string; } +type MessageAttachmentResult = + | { readonly ok: true; readonly attachments: AttachmentRef[] } + | { readonly ok: false; readonly reason: 'attachment_blocked'; readonly code: AttachmentIngestBlockedCode }; + +async function prepareMessageAttachments(input: { + deps: Pick; + getSenderId: () => number; + sessionId: string; + retainedAttachments: readonly AttachmentRef[]; + attachmentItems: unknown; +}): Promise { + const attachments = retainedAttachmentsForSession(input.sessionId, input.retainedAttachments); + try { + if (input.attachmentItems !== undefined) { + const files = await resolveIngestItems({ + senderId: input.getSenderId(), + items: input.attachmentItems, + approvals: input.deps.attachmentApprovals, + stat: input.deps.stat, + }); + attachments.push(...await resolveAttachmentRefs({ + files, + resizeImage: input.deps.resizeImage, + snapshot: ({ name, mimeType, content }) => + input.deps.client.ingestAttachment({ sessionId: input.sessionId, name, mimeType, content }), + })); + } + } catch (error) { + if (error instanceof AttachmentIngestBlockedError) { + return { ok: false, reason: 'attachment_blocked', code: error.code }; + } + throw error; + } + return attachments.length > MAX_ATTACHMENT_COUNT + ? { ok: false, reason: 'attachment_blocked', code: 'count_limit' } + : { ok: true, attachments }; +} + export interface RuntimeHostSessionObservationIpcDeps { observations: Pick< RuntimeHostSessionObservationRegistry, @@ -379,35 +421,15 @@ export function registerRuntimeHostSessionExecutionIpc( throw new Error(`Runtime Host Session not found: ${sessionId}`); const sideConversation = isSideConversationSession(session.labels); const turnId = command.turnId ?? newId(); - let attachments = retainedAttachmentsForSession( + const attachmentResult = await prepareMessageAttachments({ + deps, + getSenderId: () => event.sender.id, sessionId, - command.retainedAttachments ?? [], - ); - if (command.attachmentItems !== undefined) { - const files = await resolveIngestItems({ - senderId: event.sender.id, - items: command.attachmentItems, - approvals: deps.attachmentApprovals, - stat: deps.stat, - }); - attachments = [ - ...attachments, - ...(await resolveAttachmentRefs({ - files, - resizeImage: deps.resizeImage, - snapshot: ({ name, mimeType, content }) => - deps.client.ingestAttachment({ - sessionId, - name, - mimeType, - content, - }), - })), - ]; - } - if (attachments.length > MAX_ATTACHMENT_COUNT) { - throw new Error("Too many attachments"); - } + retainedAttachments: command.retainedAttachments ?? [], + attachmentItems: command.attachmentItems, + }); + if (!attachmentResult.ok) return attachmentResult; + const { attachments } = attachmentResult; const displayText = command.displayText ?? (command.text.trim().length > 0 @@ -502,35 +524,15 @@ export function registerRuntimeHostSessionExecutionIpc( if (!command.messageId) throw new Error("Submitted message has no identity"); // Host admission validates the target, including reserved Sessions // such as WorkHub that intentionally do not appear in the task catalog. - let attachments = retainedAttachmentsForSession( + const attachmentResult = await prepareMessageAttachments({ + deps, + getSenderId: () => event.sender.id, sessionId, - command.retainedAttachments ?? [], - ); - if (command.attachmentItems !== undefined) { - const files = await resolveIngestItems({ - senderId: event.sender.id, - items: command.attachmentItems, - approvals: deps.attachmentApprovals, - stat: deps.stat, - }); - attachments = [ - ...attachments, - ...(await resolveAttachmentRefs({ - files, - resizeImage: deps.resizeImage, - snapshot: ({ name, mimeType, content }) => - deps.client.ingestAttachment({ - sessionId, - name, - mimeType, - content, - }), - })), - ]; - } - if (attachments.length > MAX_ATTACHMENT_COUNT) { - throw new Error("Too many attachments"); - } + retainedAttachments: command.retainedAttachments ?? [], + attachmentItems: command.attachmentItems, + }); + if (!attachmentResult.ok) return attachmentResult; + const { attachments } = attachmentResult; const displayText = command.displayText ?? (command.text.trim().length > 0 diff --git a/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts b/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts index f5c058d12a..974578f8e0 100644 --- a/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts @@ -18,11 +18,16 @@ */ import { WORKHUB_COORDINATION_SESSION_ID } from '@maka/core/session'; +import { AttachmentIngestBlockedError } from '@maka/core/attachments'; import { RuntimeHostOperationError, RuntimeHostRequestInterruptedError } from '@maka/runtime-host/client'; import { prepareIngestItems, resolveAttachmentRefs } from './attachment-ingest.js'; import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; import { handleReconciledControl, rethrowReconnectableReadFailure, type ReconnectableReadIpcMain } from './ipc-reconnect-policy.js'; -import type { WorkHubAnswerInput, WorkHubAnswerResult } from '../shared/workhub-conversation.js'; +import type { + WorkHubAnswerInput, + WorkHubAnswerResult, + WorkHubPrepareAttachmentsResult, +} from '../shared/workhub-conversation.js'; import { toDesktopHostSessionSummary } from './runtime-host-session-catalog-ipc-main.js'; type RuntimeHostWorkHubClient = Pick< @@ -100,14 +105,19 @@ export function registerRuntimeHostWorkHubIpc( reconciliationUnavailable: async (attempt) => unknown(attempt), }); ipcMain.handle('workhub:configureModel', (_event, input) => client.configureWorkHubModel(input)); - ipcMain.handle('workhub:prepareAttachments', async (event, items: unknown) => { + ipcMain.handle('workhub:prepareAttachments', async (event, items: unknown): Promise => { if (!options.attachmentIngest) throw new Error('WorkHub attachments are unavailable'); - const prepared = await prepareIngestItems({ ...options.attachmentIngest, senderId: event.sender.id, items }); - const refs = await resolveAttachmentRefs({ - files: prepared.files, - resizeImage: options.attachmentIngest.resizeImage, - snapshot: ({ name, mimeType, content }) => client.ingestAttachment({ sessionId: WORKHUB_COORDINATION_SESSION_ID, name, mimeType, content }), - }); - return prepared.commit(() => refs); + try { + const prepared = await prepareIngestItems({ ...options.attachmentIngest, senderId: event.sender.id, items }); + const refs = await resolveAttachmentRefs({ + files: prepared.files, + resizeImage: options.attachmentIngest.resizeImage, + snapshot: ({ name, mimeType, content }) => client.ingestAttachment({ sessionId: WORKHUB_COORDINATION_SESSION_ID, name, mimeType, content }), + }); + return { ok: true, attachments: prepared.commit(() => refs) }; + } catch (error) { + if (error instanceof AttachmentIngestBlockedError) return { ok: false, code: error.code }; + throw error; + } }); } diff --git a/apps/desktop/src/main/session-local-service.ts b/apps/desktop/src/main/session-local-service.ts index 0295a09431..15805d74b6 100644 --- a/apps/desktop/src/main/session-local-service.ts +++ b/apps/desktop/src/main/session-local-service.ts @@ -20,7 +20,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { stat } from 'node:fs/promises'; import type { IpcMain } from 'electron'; -import { MAX_ATTACHMENT_COUNT } from '@maka/core/attachments'; +import { AttachmentIngestBlockedError, MAX_ATTACHMENT_COUNT } from '@maka/core/attachments'; import type { CreateSessionRequestInput } from '@maka/core/runtime-inputs'; import { RuntimeHostOperationError, @@ -43,7 +43,7 @@ import { import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; import { normalizeSessionSendCommand } from './permission-response-guard.js'; import type { AttachmentApprovalRegistry } from './attachment-approval.js'; -import { resolveAttachmentRefs, prepareIngestItems } from './attachment-ingest.js'; +import { resolveAttachmentRefs, prepareIngestItems, type AttachmentSnapshotInput } from './attachment-ingest.js'; import { mergeWorkspaceFileInlineReferences } from './session-workspace-inline-references.js'; import { resolveDesktopSessionCreateInput, @@ -585,24 +585,34 @@ export function registerDesktopSessionLocalIpc(deps: { if (attachment.ref.kind !== 'session_file' || attachment.ref.sessionId !== sessionId) throw new Error('Retained attachment belongs to another Session'); } - const prepared = await prepareIngestItems({ - senderId: event.sender.id, - items: command.attachmentItems ?? [], - approvals: deps.approvals, - stat, - maxAttachments: MAX_ATTACHMENT_COUNT - retained.length, - maxTotalBytes: MAX_LOCAL_MESSAGE_BYTES, - }); - const staged = await resolveAttachmentRefs({ - files: prepared.files, - maxTotalBytes: MAX_LOCAL_MESSAGE_BYTES, - resizeImage: deps.resizeImage, - snapshot: async ({ name, mimeType, content }) => ({ - name, - mimeType, - base64: Buffer.from(content).toString('base64'), - }), + const snapshot = async ({ name, mimeType, content }: AttachmentSnapshotInput) => ({ + name, + mimeType, + base64: Buffer.from(content).toString('base64'), }); + let prepared: Awaited>; + let staged: Awaited>>>>; + try { + prepared = await prepareIngestItems({ + senderId: event.sender.id, + items: command.attachmentItems ?? [], + approvals: deps.approvals, + stat, + maxAttachments: MAX_ATTACHMENT_COUNT - retained.length, + maxTotalBytes: MAX_LOCAL_MESSAGE_BYTES, + }); + staged = await resolveAttachmentRefs({ + files: prepared.files, + maxTotalBytes: MAX_LOCAL_MESSAGE_BYTES, + resizeImage: deps.resizeImage, + snapshot, + }); + } catch (error) { + if (error instanceof AttachmentIngestBlockedError) { + return { ok: false as const, reason: 'attachment_blocked' as const, code: error.code }; + } + throw error; + } // Revalidate authority after asynchronous file reads and native resizing. if (service.target(scope).partition !== target.partition) throw new Error('Host authority changed while saving the message'); @@ -611,26 +621,35 @@ export function registerDesktopSessionLocalIpc(deps: { displayText, workspaceFileReferences: command.workspaceFileReferences, }); - prepared.commit(() => - service.store.enqueue(target.partition, { - staged, - command: { - sessionId, - messageId, - placement, - content: { - text: command.text, - ...(command.displayText !== undefined ? { displayText } : {}), - attachments: retained, - directoryReferences: command.directoryReferences, - quotes: command.quotes, - inlineReferences, + try { + // The approval can be consumed while the reads above were in flight, so + // admission is part of the same conversion to the envelope. + prepared.commit(() => + service.store.enqueue(target.partition, { + staged, + command: { + sessionId, + messageId, + placement, + content: { + text: command.text, + ...(command.displayText !== undefined ? { displayText } : {}), + attachments: retained, + directoryReferences: command.directoryReferences, + quotes: command.quotes, + inlineReferences, + }, + ...(command.skillIds?.length ? { skillIds: command.skillIds } : {}), + ...(command.turnOrchestration ? { turnOrchestration: command.turnOrchestration } : {}), }, - ...(command.skillIds?.length ? { skillIds: command.skillIds } : {}), - ...(command.turnOrchestration ? { turnOrchestration: command.turnOrchestration } : {}), - }, - }), - ); + }), + ); + } catch (error) { + if (error instanceof AttachmentIngestBlockedError) { + return { ok: false as const, reason: 'attachment_blocked' as const, code: error.code }; + } + throw error; + } deps.changed(target.scope, sessionId); service.wake(); return { diff --git a/apps/desktop/src/preload/attachment-ingest-payload.ts b/apps/desktop/src/preload/attachment-ingest-payload.ts index 15ab2966d2..a108d5a743 100644 --- a/apps/desktop/src/preload/attachment-ingest-payload.ts +++ b/apps/desktop/src/preload/attachment-ingest-payload.ts @@ -18,7 +18,7 @@ */ import { - attachmentIngestBlocked, + AttachmentIngestBlockedError, MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_COUNT, } from '@maka/core/attachments'; @@ -41,14 +41,14 @@ function bytesToBase64(bytes: Uint8Array): string { } export async function encodeIngestItems(items: IngestInput[]): Promise { - if (items.length > MAX_ATTACHMENT_COUNT) throw attachmentIngestBlocked('count_limit'); + if (items.length > MAX_ATTACHMENT_COUNT) throw new AttachmentIngestBlockedError('count_limit'); 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 attachmentIngestBlocked('item_too_large'); + if (item.file.size > MAX_ATTACHMENT_BYTES) throw new AttachmentIngestBlockedError('item_too_large'); const bytes = new Uint8Array(await item.file.arrayBuffer()); const mimeType = item.file.type || undefined; out.push({ @@ -59,7 +59,7 @@ export async function encodeIngestItems(items: IngestInput[]): Promise; - prepareAttachments(coordinationSessionId: string, items: RendererIngestInput[]): Promise; + prepareAttachments(coordinationSessionId: string, items: RendererIngestInput[]): Promise; answer(coordinationSessionId: string, input: WorkHubAnswerInput): Promise; configureModel(coordinationSessionId: string, input: OperationInput<'workhub.coordination.configureModel'>): Promise>; /** Resolve the active Runtime Host's stable coordination conversation. */ @@ -1116,6 +1123,11 @@ export interface MakaBridge { reason: 'skill_invocation_failed'; skillInvocation: import('@maka/runtime/skill-invocation').SkillInvocationResult; } + | { + ok: false; + reason: 'attachment_blocked'; + code: import('@maka/core/attachments').AttachmentIngestBlockedCode; + } | { ok: false; reason: 'outcome_unknown'; @@ -1169,6 +1181,11 @@ export interface MakaBridge { reason: 'skill_invocation_failed'; skillInvocation: import('@maka/runtime/skill-invocation').SkillInvocationResult; } + | { + ok: false; + reason: 'attachment_blocked'; + code: import('@maka/core/attachments').AttachmentIngestBlockedCode; + } | { ok: false; reason: 'outcome_unknown' } >; queryCancelledMessages( @@ -1238,22 +1255,22 @@ export interface MakaBridge { unarchive(sessionId: string, options?: { revisionFamily?: boolean }): Promise; setFlagged(sessionId: string, isFlagged: boolean, options?: { revisionFamily?: boolean }): Promise; rename(sessionId: string, name: string, options?: { revisionFamily?: boolean }): Promise; - setPermissionMode(sessionId: string, mode: PermissionMode): Promise; + setPermissionMode(sessionId: string, mode: PermissionMode): Promise>; /** * Enter or leave Plan — a temporary collaboration excursion Runtime ends * by itself once a proposal is approved or abandoned. */ - setCollaborationMode(sessionId: string, mode: CollaborationMode): Promise; + setCollaborationMode(sessionId: string, mode: CollaborationMode): Promise>; /** * The Session's standing default for how a turn fans out. Independent of * Plan: different field, different lifetime, and Runtime resolves the * overlap by stripping the tools Swarm and Graph need while planning. */ - setOrchestrationMode(sessionId: string, mode: OrchestrationMode): Promise; + setOrchestrationMode(sessionId: string, mode: OrchestrationMode): Promise>; getPlanState(sessionId: string): Promise; subscribePlanChanges(sessionId: string, handler: () => void): () => void; requestPlanRevision(sessionId: string, proposalId: string): Promise>; - abandonPlanProposal(sessionId: string, proposalId: string): Promise; + abandonPlanProposal(sessionId: string, proposalId: string): Promise>; approvePlan(sessionId: string, input: { proposalId: string; expectedRevision: number; @@ -1270,8 +1287,8 @@ export interface MakaBridge { llmConnectionSlug: string; model: string; thinkingLevel: ThinkingLevel | null; - }): Promise; - setThinkingLevel(sessionId: string, level: ThinkingLevel | undefined | null): Promise; + }): Promise>; + setThinkingLevel(sessionId: string, level: ThinkingLevel | undefined | null): Promise>; /** * `requireArchived` holds the caller's premise through the deletion: a task * restored meanwhile answers `restored` and is kept. `archivedSubtaskCount` diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index fe6e89bf3b..21f105fdb9 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -22,7 +22,11 @@ import type { SessionBundleImportIpcResult, } from './bridge-contract.js'; -import type { WorkHubAnswerInput, WorkHubAnswerResult } from '../shared/workhub-conversation.js'; +import type { + WorkHubAnswerInput, + WorkHubAnswerResult, + WorkHubPrepareAttachmentsResult, +} from '../shared/workhub-conversation.js'; import type { SessionObservationMessage } from '../shared/session-execution-projection.js'; import { contextBridge, ipcRenderer } from 'electron'; import { workHubControlBridge } from './workhub-control.js'; @@ -31,6 +35,7 @@ import { isRuntimeHostProfileKind, type RuntimeHostProfileKind, } from '@maka/runtime-host/profile-kind'; +import { AttachmentIngestBlockedError } from '@maka/core/attachments'; import { encodeIngestItems } from './attachment-ingest-payload.js'; import { collectThreadSearchResponses } from './multi-host-thread-search.js'; import { releaseSessionObservation } from './session-observation-release.js'; @@ -265,6 +270,7 @@ import { projectDesktopUsageStats, type DesktopSessionSummary, type DesktopSessionSummaryInput, + type DesktopSessionUpdateResult, } from '../shared/desktop-session-projection.js'; import { projectDesktopSharedSessionSummary } from '../shared/shared-session-catalog-projection.js'; @@ -732,19 +738,21 @@ async function invokeProjectedSessionRuntimeHost( ); } -async function invokeSessionSummary( +async function invokeSessionUpdate( channel: string, sessionId: string, ...args: unknown[] -): Promise { +): Promise> { const session = await runtimeHostSessionRef(sessionId); - const summary = await ipcRenderer.invoke( + const result = (await ipcRenderer.invoke( channel, session.scope, session.sessionId, ...args, - ) as DesktopSessionSummaryInput; - return projectSessionSummary(session.scope, summary); + )) as DesktopSessionUpdateResult; + return result.ok + ? { ok: true, session: projectSessionSummary(session.scope, result.session) } + : result; } async function invokeBranchFromTurn( @@ -2044,8 +2052,21 @@ const makaBridge = { }, async prepareAttachments(coordinationSessionId: string, items: Parameters[1]) { const scope = await resolveDesktopWorkHubCoordinationCreateScope(coordinationSessionId, runtimeHostSessionRef); - const attachments = await ipcRenderer.invoke('workhub:prepareAttachments', scope, await encodeIngestItems(items)) as AttachmentRef[]; - return projectDesktopAttachmentRefs(scope, attachments); + let encoded: Awaited>; + try { + encoded = await encodeIngestItems(items); + } catch (error) { + if (error instanceof AttachmentIngestBlockedError) return { ok: false, code: error.code }; + throw error; + } + const result = await ipcRenderer.invoke( + 'workhub:prepareAttachments', + scope, + encoded, + ) as WorkHubPrepareAttachmentsResult; + return result.ok + ? { ok: true, attachments: projectDesktopAttachmentRefs(scope, result.attachments) } + : result; }, async answer(coordinationSessionId: string, input: WorkHubAnswerInput) { const scope = await resolveDesktopWorkHubCoordinationCreateScope(coordinationSessionId, runtimeHostSessionRef); @@ -2113,10 +2134,19 @@ const makaBridge = { if (command.directoryReferences?.some((ref) => ref.hostId !== session.scope.hostId)) { throw new Error('Directory references belong to a different Runtime Host. Select the folder on the target Host.'); } - const encoded = - 'attachmentItems' in command && command.attachmentItems - ? { ...command, attachmentItems: await encodeIngestItems(command.attachmentItems) } - : command; + let attachmentItems: Awaited> | undefined; + try { + attachmentItems = + 'attachmentItems' in command && command.attachmentItems + ? await encodeIngestItems(command.attachmentItems) + : undefined; + } catch (error) { + if (error instanceof AttachmentIngestBlockedError) { + return { ok: false, reason: 'attachment_blocked', code: error.code }; + } + throw error; + } + const encoded = attachmentItems ? { ...command, attachmentItems } : command; const result = (await ipcRenderer.invoke( 'sessions:send', session.scope, @@ -2151,9 +2181,17 @@ const makaBridge = { if (command.directoryReferences?.some((ref) => ref.hostId !== session.scope.hostId)) { throw new Error('Directory references belong to a different Runtime Host. Select the folder on the target Host.'); } - const attachmentItems = command.attachmentItems - ? await encodeIngestItems(command.attachmentItems) - : undefined; + let attachmentItems: Awaited> | undefined; + try { + attachmentItems = command.attachmentItems + ? await encodeIngestItems(command.attachmentItems) + : undefined; + } catch (error) { + if (error instanceof AttachmentIngestBlockedError) { + return { ok: false, reason: 'attachment_blocked', code: error.code }; + } + throw error; + } const result = (await ipcRenderer.invoke( options?.waitForHostAdmission ? 'sessions:submitMessage' : 'session-local:submit', session.scope, @@ -2399,14 +2437,14 @@ const makaBridge = { rename(sessionId: string, name: string, options?: { revisionFamily?: boolean }): Promise { return invokeSessionRuntimeHost('sessions:rename', sessionId, name, options); }, - setPermissionMode(sessionId: string, mode: PermissionMode): Promise { - return invokeSessionSummary('sessions:setPermissionMode', sessionId, mode); + setPermissionMode(sessionId: string, mode: PermissionMode): Promise> { + return invokeSessionUpdate('sessions:setPermissionMode', sessionId, mode); }, - setCollaborationMode(sessionId: string, mode: CollaborationMode): Promise { - return invokeSessionSummary('sessions:setCollaborationMode', sessionId, mode); + setCollaborationMode(sessionId: string, mode: CollaborationMode): Promise> { + return invokeSessionUpdate('sessions:setCollaborationMode', sessionId, mode); }, - setOrchestrationMode(sessionId: string, mode: OrchestrationMode): Promise { - return invokeSessionSummary('sessions:setOrchestrationMode', sessionId, mode); + setOrchestrationMode(sessionId: string, mode: OrchestrationMode): Promise> { + return invokeSessionUpdate('sessions:setOrchestrationMode', sessionId, mode); }, getPlanState(sessionId: string): Promise { return invokeProjectedSessionRuntimeHost('plan-mode:getState', sessionId); @@ -2437,8 +2475,8 @@ const makaBridge = { abandonPlanProposal( sessionId: string, proposalId: string, - ): Promise { - return invokeProjectedSessionRuntimeHost('plan-mode:abandon', sessionId, proposalId); + ): Promise> { + return invokeProjectedSessionRuntimeHost('plan-mode:abandon', sessionId, proposalId); }, approvePlan(sessionId: string, input: { proposalId: string; @@ -2462,11 +2500,11 @@ const makaBridge = { llmConnectionSlug: string; model: string; thinkingLevel: ThinkingLevel | null; - }): Promise { - return invokeSessionSummary('sessions:setModelConfiguration', sessionId, input); + }): Promise> { + return invokeSessionUpdate('sessions:setModelConfiguration', sessionId, input); }, - setThinkingLevel(sessionId: string, level: ThinkingLevel | undefined | null): Promise { - return invokeSessionSummary('sessions:setThinkingLevel', sessionId, level ?? undefined); + setThinkingLevel(sessionId: string, level: ThinkingLevel | undefined | null): Promise> { + return invokeSessionUpdate('sessions:setThinkingLevel', sessionId, level ?? undefined); }, async remove( sessionId: string, diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 359872e4b7..a521c1cb65 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -234,18 +234,11 @@ export function createAppShellChatActions(deps: { removeTransientMessage(sessionId, turnId); } - /** - * What a submitted Message became, as far as this client can tell. - * - * `unreconciled` is the only outcome that leaves the transient row in place: - * the answer was lost, so Runtime Host may well have acted on the Message and - * canonical transcript is what settles it. A `refused` Message opened no Turn - * and will never be replaced by a canonical one, so its row is already gone. - */ + /** Only an unreconciled submission keeps its row because Host admission may have succeeded. */ type SubmittedMessage = | { kind: 'projected'; skillInvocation: SkillInvocationResult; turnId?: string } | { kind: 'unreconciled' } - | { kind: 'refused'; skillInvocation: SkillInvocationResult }; + | { kind: 'refused' }; /** * The one place a submitted Message's outcome becomes UI. Every submission — @@ -282,17 +275,13 @@ export function createAppShellChatActions(deps: { return { kind: 'unreconciled' }; } removeOptimisticUserMessage(sessionId, messageId); - if (surfaceVisible) { - skillFeedback.showSkillInvocationFeedback(uiLocale, toastApi, result.skillInvocation, sessionId); - } - return { kind: 'refused', skillInvocation: result.skillInvocation }; + if (surfaceVisible) skillFeedback.showSubmissionFeedback(uiLocale, toastApi, result, sessionId); + return { kind: 'refused' }; } if (result.disposition === 'locally_saved') { return { kind: 'projected', skillInvocation: result.skillInvocation }; } - if (surfaceVisible) { - skillFeedback.showSkillInvocationFeedback(uiLocale, toastApi, result.skillInvocation, sessionId); - } + if (surfaceVisible) skillFeedback.showSubmissionFeedback(uiLocale, toastApi, result, sessionId); // The row is updated whether or not the surface is on screen: attachments, // inline references and the Host Turn grouping are what the user finds when // they come back to it. diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index b9f6dd34ac..47068f73dc 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -850,8 +850,8 @@ function AppShellContent({ if (!confirmed) return false; // Abandoning the proposal is what leaves Plan: Runtime writes the // Session back to `agent` itself as part of it. - await window.maka.sessions.abandonPlanProposal(sessionId, latestProposal.proposalId); - } else await window.maka.sessions.setCollaborationMode(sessionId, active ? 'plan' : 'agent'); + await sessionSettingIntent.abandonPlanProposal(sessionId, latestProposal.proposalId); + } else await sessionSettingIntent.setCollaborationMode(sessionId, active ? 'plan' : 'agent'); return true; } diff --git a/apps/desktop/src/renderer/attachment-preflight.ts b/apps/desktop/src/renderer/attachment-preflight.ts index a0b820d810..d5b1a0da1d 100644 --- a/apps/desktop/src/renderer/attachment-preflight.ts +++ b/apps/desktop/src/renderer/attachment-preflight.ts @@ -18,7 +18,7 @@ */ import { - attachmentIngestBlocked, + AttachmentIngestBlockedError, MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_COUNT, } from '@maka/core/attachments'; @@ -31,25 +31,14 @@ type PreflightItem = { | { type: 'retained' }; }; -/** - * Reject count/size/duplicate-source violations before a new-chat session is - * created, so an encode/resolve-time failure does not leave an empty session - * behind. Rejects with the same stable `attachment_ingest:` tokens the - * main-side resolveIngestItems pre-validation rejects with, so the shell - * presenter maps the reason through the locale catalogs instead of the - * generic send fallback; main remains the authoritative cap. - * - * File blobs are sized by the browser File object; approval-token attachments - * are sized by the pending size stamped at pick time (main re-stats). - */ export function preflightAttachmentItems(items: readonly PreflightItem[]): void { - if (items.length > MAX_ATTACHMENT_COUNT) throw attachmentIngestBlocked('count_limit'); + if (items.length > MAX_ATTACHMENT_COUNT) throw new AttachmentIngestBlockedError('count_limit'); const seen = new Set(); for (const item of items) { const bytes = item.source.type === 'file' ? item.source.file.size : item.size; - if (bytes > MAX_ATTACHMENT_BYTES) throw attachmentIngestBlocked('item_too_large'); + if (bytes > MAX_ATTACHMENT_BYTES) throw new AttachmentIngestBlockedError('item_too_large'); if (item.source.type === 'approval') { - if (seen.has(item.source.approvalId)) throw attachmentIngestBlocked('duplicate_source'); + if (seen.has(item.source.approvalId)) throw new AttachmentIngestBlockedError('duplicate_source'); seen.add(item.source.approvalId); } } diff --git a/apps/desktop/src/renderer/features/session-settings/ports.ts b/apps/desktop/src/renderer/features/session-settings/ports.ts index 32f1993941..9e6a155777 100644 --- a/apps/desktop/src/renderer/features/session-settings/ports.ts +++ b/apps/desktop/src/renderer/features/session-settings/ports.ts @@ -18,6 +18,7 @@ */ import type { ThinkingLevel } from '@maka/core/model-thinking'; +import type { CollaborationMode } from '@maka/core/collaboration'; import type { OrchestrationMode } from '@maka/core/orchestration'; import type { ChatDefaultPermissionMode } from '@maka/core/settings'; import type { DesktopSessionSummary } from '../../../shared/desktop-session-projection.js'; @@ -36,4 +37,6 @@ export interface SessionSettingsServices { sessionId: string, mode: OrchestrationMode, ): Promise; + setCollaborationMode(sessionId: string, mode: CollaborationMode): Promise; + abandonPlanProposal(sessionId: string, proposalId: string): Promise; } diff --git a/apps/desktop/src/renderer/features/session-settings/use-session-setting-intent.ts b/apps/desktop/src/renderer/features/session-settings/use-session-setting-intent.ts index d597ff60f9..92c64d42f9 100644 --- a/apps/desktop/src/renderer/features/session-settings/use-session-setting-intent.ts +++ b/apps/desktop/src/renderer/features/session-settings/use-session-setting-intent.ts @@ -135,6 +135,8 @@ export function useSessionSettingIntent(in return { clear: intent.clear, + abandonPlanProposal: services.abandonPlanProposal, + setCollaborationMode: services.setCollaborationMode, overlays: intent.overlayByChannel, setSessionModel: (sessionId: string, modelTarget: SessionModelTarget) => intent.request('modelConfiguration', sessionId, modelConfigurationIntentForModel(modelTarget)), diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index 56fae472cb..b5abb54eb9 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -31,7 +31,8 @@ import { generalizedErrorMessageForLocale, unexpectedOperationFallback, } from '@maka/core/redaction'; -import type { AttachmentIngestBlockedCode } from '@maka/core/attachments'; +import { AttachmentIngestBlockedError, type AttachmentIngestBlockedCode } from '@maka/core/attachments'; +import type { DesktopSessionUpdateFailureCode } from '../../shared/desktop-session-projection.js'; export const STATIC_COMMAND_IDS = [ 'action:new-chat', @@ -349,6 +350,7 @@ type ShellCopy = { bypassCancelLabel: string; permissionFailedTitle: string; permissionFallback: string; + updateFailures: Record; attachmentIngestBlocked: Record; modelFailedTitle: string; modelFallback: string; @@ -1004,6 +1006,12 @@ const SHELL_COPY_BY_LOCALE = { bypassCancelLabel: '保持自动', permissionFailedTitle: '切换权限模式失败', permissionFallback: '权限模式暂时无法切换,请稍后重试。', + updateFailures: { + session_busy: '当前任务正在运行或有交互待处理,等结束后再改设置。', + operation_conflict: '任务状态刚刚变化,请刷新后重试。', + operation_unavailable: '当前 Runtime Host 不支持此设置。', + not_found: '任务不存在,可能已被删除。', + }, attachmentIngestBlocked: { item_too_large: '单个附件超出大小限制。', items_invalid: '附件信息无效,请重新选择文件后再发送。', @@ -1505,6 +1513,12 @@ const SHELL_COPY_BY_LOCALE = { bypassCancelLabel: '保持自動', permissionFailedTitle: '切換權限模式失敗', permissionFallback: '權限模式暫時無法切換,請稍後重試。', + updateFailures: { + session_busy: '目前任務正在執行或有互動待處理,等結束後再改設定。', + operation_conflict: '任務狀態剛剛變化,請重新整理後重試。', + operation_unavailable: '目前 Runtime Host 不支援此設定。', + not_found: '任務不存在,可能已被刪除。', + }, attachmentIngestBlocked: { item_too_large: '單一附件超出大小限制。', items_invalid: '附件資訊無效,請重新選擇檔案後再傳送。', @@ -2012,6 +2026,12 @@ const SHELL_COPY_BY_LOCALE = { bypassCancelLabel: 'Keep Auto', permissionFailedTitle: 'Could not change permission mode', permissionFallback: 'The permission mode could not be changed. Try again later.', + updateFailures: { + session_busy: 'A task is running or waiting on you. Change this setting after it settles.', + operation_conflict: 'The task changed underneath this request. Refresh and try again.', + operation_unavailable: 'This Runtime Host does not support that setting.', + not_found: 'The task no longer exists.', + }, attachmentIngestBlocked: { item_too_large: 'One attachment exceeds the size limit.', items_invalid: 'The attachment list is invalid. Pick the files again and resend.', @@ -2316,17 +2336,8 @@ export function getShellCopy(locale: UiLocale): ShellCopy { } export function localizedShellErrorMessage(error: unknown, fallback: string, locale: UiLocale): string { - const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''; - const maps = getShellCopy(locale).sessionSettingsActions; - // The reason token survives the Electron IPC wrapper and is always the - // message tail: a bare `attachment_ingest:` from the preload probe or - // the IPC-wrapped error line. End-anchored so an unrelated path that merely - // contains the substring never matches. - const blocked = lookupCopy( - maps.attachmentIngestBlocked, - message.match(/(?:^|[ :"'])attachment_ingest:([a-z_]+)$/u)?.[1], - ); - if (blocked) return blocked; + if (error instanceof AttachmentIngestBlockedError) + return getShellCopy(locale).sessionSettingsActions.attachmentIngestBlocked[error.code]; // A classified failure (timeout / rate limit / auth / provider / network) // is expected; only an unrecognized one lands the redacted diagnostic. return classifyGeneralizedError(error) @@ -2351,10 +2362,16 @@ export function sessionSettingFailureCopy( : { title: copy.app.orchestrationModeFailedTitle, fallback: copy.app.orchestrationModeFallback }; return { title: failure.title, - description: localizedShellErrorMessage(error, failure.fallback, locale), + description: + lookupCopy(copy.sessionSettingsActions.updateFailures, expectedOperationCode(error)) ?? + localizedShellErrorMessage(error, failure.fallback, locale), }; } +function expectedOperationCode(error: unknown): string | undefined { + return error instanceof Error && error.name === 'ExpectedOperationError' ? error.message : undefined; +} + export function confirmBypassPermission( toast: { confirm(input: { diff --git a/apps/desktop/src/renderer/platform/desktop/create-session-settings-services.ts b/apps/desktop/src/renderer/platform/desktop/create-session-settings-services.ts index b0850c9fe5..a42d449c37 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-session-settings-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-session-settings-services.ts @@ -18,18 +18,32 @@ */ import type { MakaBridge } from '../../../preload/bridge-contract.js'; +import type { DesktopSessionUpdateResult } from '../../../shared/desktop-session-projection.js'; +import { ExpectedOperationError } from '../../application/contracts/operation-diagnostics.js'; import type { SessionSettingsServices } from '../../features/session-settings'; export type DesktopSessionSettingsBridge = Pick; +export function expectSessionUpdate(result: DesktopSessionUpdateResult): Session { + if (result.ok) return result.session; + throw new ExpectedOperationError(result.code); +} + export function createDesktopSessionSettingsServices( bridge: DesktopSessionSettingsBridge = window.maka, ): SessionSettingsServices { return { - setModelConfiguration: (sessionId, input) => - bridge.sessions.setModelConfiguration(sessionId, input), - setPermissionMode: (sessionId, mode) => bridge.sessions.setPermissionMode(sessionId, mode), - setOrchestrationMode: (sessionId, mode) => - bridge.sessions.setOrchestrationMode(sessionId, mode), + setModelConfiguration: async (sessionId, input) => + expectSessionUpdate(await bridge.sessions.setModelConfiguration(sessionId, input)), + setPermissionMode: async (sessionId, mode) => + expectSessionUpdate(await bridge.sessions.setPermissionMode(sessionId, mode)), + setOrchestrationMode: async (sessionId, mode) => + expectSessionUpdate(await bridge.sessions.setOrchestrationMode(sessionId, mode)), + setCollaborationMode: async (sessionId, mode) => + expectSessionUpdate(await bridge.sessions.setCollaborationMode(sessionId, mode)), + abandonPlanProposal: async (sessionId, proposalId) => { + const result = await bridge.sessions.abandonPlanProposal(sessionId, proposalId); + if (!result.ok) throw new ExpectedOperationError(result.error.code); + }, }; } diff --git a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts index 2ef12f8fc0..9ace25150e 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts @@ -23,6 +23,7 @@ import { isTerminalShellRunStatus } from '@maka/core/shell-run'; import { DESKTOP_TERMINAL_LAUNCH_PREFIX } from '../../../shared/runtime-host-identity.js'; import type { WorkbarServices } from '../../features/workbar'; import { readSettledMessagesFrom } from './session-message-settlement.js'; +import { expectSessionUpdate } from './create-session-settings-services.js'; export type DesktopWorkbarBridge = Pick< MakaBridge, @@ -186,8 +187,8 @@ export function createDesktopWorkbarServices( bridge.sessions.updateQueueEntry(sessionId, entryId, expectedQueueRevision, text), reorderQueueEntries: (sessionId, entryIds) => bridge.sessions.reorderQueueEntries(sessionId, entryIds), - setPermissionMode: (sessionId, mode) => - bridge.sessions.setPermissionMode(sessionId, mode), + setPermissionMode: async (sessionId, mode) => + expectSessionUpdate(await bridge.sessions.setPermissionMode(sessionId, mode)), regenerateTurn: (sessionId, input) => bridge.sessions.regenerateTurn(sessionId, input), respondToSandboxBoundary: (sessionId, response) => diff --git a/apps/desktop/src/renderer/platform/desktop/create-workhub-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workhub-services.ts index acfb09fb32..e629a4c0ac 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workhub-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workhub-services.ts @@ -19,6 +19,7 @@ import { resolveSystemUiLocale, resolveUiLocale } from '@maka/core/ui-locale'; import { DEFAULT_UI_FONT_SIZE } from '@maka/core/settings'; +import { AttachmentIngestBlockedError } from '@maka/core/attachments'; import { applyDocumentThemeMode, applyDocumentThemePalette, applyDocumentUiFontSize } from './document-appearance.js'; import type { MakaBridge } from '../../../preload/bridge-contract.js'; import { @@ -220,7 +221,11 @@ export function createDesktopWorkHubServices( (await bridge.connections.getSnapshot(sessionId)).chatModelChoices, attachments: bridge.attachments, readAttachmentBytes: bridge.attachments.readBytes, - prepareAttachments: (sessionId, items) => bridge.workHub.prepareAttachments(sessionId, items), + prepareAttachments: async (sessionId, items) => { + const result = await bridge.workHub.prepareAttachments(sessionId, items); + if (!result.ok) throw new AttachmentIngestBlockedError(result.code); + return result.attachments; + }, listActiveInteractions: (sessionId) => bridge.sessions.listActiveInteractions(sessionId), subscribeActiveInteractions: (handler) => bridge.sessions.subscribeActiveInteractions(handler), respondToUserForm: (sessionId, response) => bridge.sessions.respondToUserForm(sessionId, response), diff --git a/apps/desktop/src/renderer/skill-invocation-feedback.ts b/apps/desktop/src/renderer/skill-invocation-feedback.ts index 3dad2d0a84..4e71b0817c 100644 --- a/apps/desktop/src/renderer/skill-invocation-feedback.ts +++ b/apps/desktop/src/renderer/skill-invocation-feedback.ts @@ -17,11 +17,12 @@ * under the License. */ +import type { AttachmentIngestBlockedCode } from '@maka/core/attachments'; import type { UiLocale } from '@maka/core/ui-locale'; import type { SkillInvocationResult } from '@maka/runtime/skill-invocation'; import { getShellCopy } from './locales/shell-copy.js'; -type SkillInvocationToastApi = { +type FeedbackToastApi = { error( title: string, description?: string, @@ -31,6 +32,38 @@ type SkillInvocationToastApi = { info(title: string, description?: string): void; }; +type SubmissionFeedback = + | { skillInvocation: SkillInvocationResult } + | { reason: 'attachment_blocked'; code: AttachmentIngestBlockedCode }; + +export function showSubmissionFeedback( + uiLocale: UiLocale, + toastApi: FeedbackToastApi, + outcome: SubmissionFeedback, + sessionId: string, +): void { + if ('code' in outcome) { + showAttachmentIngestBlockedFeedback(uiLocale, toastApi, outcome.code, sessionId); + return; + } + showSkillInvocationFeedback(uiLocale, toastApi, outcome.skillInvocation, sessionId); +} + +function showAttachmentIngestBlockedFeedback( + uiLocale: UiLocale, + toastApi: FeedbackToastApi, + code: AttachmentIngestBlockedCode, + sessionId: string, +): void { + const copy = getShellCopy(uiLocale); + toastApi.error( + copy.chatActions.sendFailedTitle, + copy.sessionSettingsActions.attachmentIngestBlocked[code], + undefined, + { sessionId }, + ); +} + /** Match main-process persistence for a chip-only optimistic user message. */ export function skillInvocationDisplayText( text: string, @@ -41,9 +74,9 @@ export function skillInvocationDisplayText( } /** The Composer is the only Desktop surface that invokes Skills (#1433). */ -export function showSkillInvocationFeedback( +function showSkillInvocationFeedback( uiLocale: UiLocale, - toastApi: SkillInvocationToastApi, + toastApi: FeedbackToastApi, skillInvocation: SkillInvocationResult, sessionId: string, ): void { diff --git a/apps/desktop/src/shared/desktop-session-projection.ts b/apps/desktop/src/shared/desktop-session-projection.ts index ea1ae94874..991f30547a 100644 --- a/apps/desktop/src/shared/desktop-session-projection.ts +++ b/apps/desktop/src/shared/desktop-session-projection.ts @@ -48,6 +48,16 @@ export interface DesktopSessionSummary extends SessionSummary { export type DesktopSessionSummaryInput = SessionSummary & { readonly revision: number; readonly localState?: 'pending' | 'cached'; readonly localCreatedAt?: number }; +export type DesktopSessionUpdateFailureCode = + | 'session_busy' + | 'operation_conflict' + | 'operation_unavailable' + | 'not_found'; + +export type DesktopSessionUpdateResult = + | { readonly ok: true; readonly session: Session } + | { readonly ok: false; readonly code: DesktopSessionUpdateFailureCode }; + export interface DesktopSessionHost extends DesktopHostRef { readonly profileId: string; readonly profileName: string; diff --git a/apps/desktop/src/shared/workhub-conversation.d.ts b/apps/desktop/src/shared/workhub-conversation.d.ts index b30a677957..b091448427 100644 --- a/apps/desktop/src/shared/workhub-conversation.d.ts +++ b/apps/desktop/src/shared/workhub-conversation.d.ts @@ -18,6 +18,8 @@ */ import type { OperationInput, TurnSnapshot } from '@maka/runtime-host/protocol'; +import type { AttachmentIngestBlockedCode } from '@maka/core/attachments'; +import type { AttachmentRef } from '@maka/core/events'; /** A retry keeps the original Host epoch as well as the Turn and complete payload. */ export type WorkHubAnswerInput = OperationInput<'workhub.coordination.answer'> & { @@ -28,3 +30,7 @@ export type WorkHubAnswerResult = | { readonly kind: 'admitted'; readonly turnId: string; readonly status?: TurnSnapshot['status'] } | { readonly kind: 'unknown'; readonly originHostEpoch: string } | { readonly kind: 'not_admitted' }; + +export type WorkHubPrepareAttachmentsResult = + | { readonly ok: true; readonly attachments: AttachmentRef[] } + | { readonly ok: false; readonly code: AttachmentIngestBlockedCode }; diff --git a/packages/core/src/attachments.ts b/packages/core/src/attachments.ts index ca709b7aef..a2f13a6984 100644 --- a/packages/core/src/attachments.ts +++ b/packages/core/src/attachments.ts @@ -293,6 +293,9 @@ export type AttachmentIngestBlockedCode = | 'source_expired' | 'total_size_exceeded'; -export function attachmentIngestBlocked(code: AttachmentIngestBlockedCode): Error { - return new Error(`attachment_ingest:${code}`); +export class AttachmentIngestBlockedError extends Error { + constructor(readonly code: AttachmentIngestBlockedCode) { + super('Attachment ingest was blocked'); + this.name = 'AttachmentIngestBlockedError'; + } } diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 03389aa465..7c525616ec 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -4644,7 +4644,11 @@ describe('SessionManager permission mode updates', () => { assert.strictEqual(backend?.stopCalls, 0); assert.deepStrictEqual(calls, []); // A fresh retry is now correctly classified as narrowing. - await assert.rejects(update(false), /当前任务正在运行|linked Turn is active/); + await assert.rejects(update(false), (error: unknown) => { + assert.ok(error instanceof SessionConfigurationTransitionError); + assert.strictEqual(error.code, 'session_busy'); + return true; + }); } finally { gate.release(); while (!(await turn.next()).done) {} @@ -5168,8 +5172,8 @@ describe('SessionManager permission mode updates', () => { await activeTurn.next(); await assert.rejects(restoreExplore(), (error: unknown) => { if (route === 'boundary') { - assert.ok(error instanceof Error); - assert.match(error.message, /当前任务正在运行/); + assert.ok(error instanceof SessionConfigurationTransitionError); + assert.strictEqual(error.code, 'session_busy'); return true; } assert.ok(error instanceof SessionConfigurationTransitionError); diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 6412669798..9a6db9b565 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -1698,10 +1698,16 @@ export class SessionManager { : header.permissionMode; const narrows = narrowsExecutionAuthority(current, permissionMode); if (narrows && this.runtimeKernel.hasActiveRuns(sessionId)) { - throw new Error('当前任务正在运行,等结束后再切换沙箱边界。'); + throw new SessionConfigurationTransitionError( + 'session_busy', + 'Execution boundary cannot change while a Turn is running', + ); } if (header.status === 'waiting_for_user') { - throw new Error('当前有沙箱边界请求正在等待确认,处理后再切换。'); + throw new SessionConfigurationTransitionError( + 'session_busy', + 'Execution boundary cannot change while an Interaction is pending', + ); } const boundary = await this.commitExecutionBoundaryTransition( sessionId, @@ -1948,20 +1954,32 @@ export class SessionManager { throw new PlanConflictError('Linked child Sessions cannot enter Plan mode'); } if (this.runtimeKernel.hasActiveRuns(sessionId)) { - throw new Error('当前任务正在运行,等结束后再切换协作模式。'); + throw new SessionConfigurationTransitionError( + 'session_busy', + 'Collaboration mode cannot change while a Turn is running', + ); } if (previous.status === 'waiting_for_user') { - throw new Error('当前有工具调用正在等待确认,处理后再切换协作模式。'); + throw new SessionConfigurationTransitionError( + 'session_busy', + 'Collaboration mode cannot change while an Interaction is pending', + ); } const planState = await this.requirePlanStore().readState(sessionId); if (mode === 'plan' && planState.activeExecutionId) { - throw new Error('当前计划仍在执行,结束或中断后才能切换到 Plan Mode。'); + throw new SessionConfigurationTransitionError( + 'session_busy', + 'An active Plan execution prevents entering Plan mode', + ); } const latestProposal = planState.proposals.find( (proposal) => proposal.proposalId === planState.latestProposalId, ); if (mode === 'agent' && latestProposal?.status === 'pending_approval') { - throw new Error('当前方案正在等待审批,请明确放弃方案后再退出 Plan Mode。'); + throw new SessionConfigurationTransitionError( + 'operation_conflict', + 'A pending Plan proposal must be resolved before leaving Plan mode', + ); } const next = await this.deps.store.updateHeader(sessionId, {