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, {