From 29ebd0c317f879406dc5478882d978f60903f257 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 13 Oct 2026 12:01:17 +0800 Subject: [PATCH] fix(runtime): preserve client capability tool outcomes Carry success, error and aborted outcomes through Client Capability settlement, durable events and transcript replay. Preserve public Computer Use binding failures and scope reused call IDs by append order. Advance the Host compatibility epoch to 220 on current main. Co-authored-by: testikun <320479488+testikun@users.noreply.github.com> Generated-by: OpenAI Codex --- .../src/main/__tests__/browser-tools.test.ts | 1 + .../mcp-form-host-integration.test.ts | 4 +- .../main/__tests__/mcp-runtime-e2e.test.ts | 2 + .../runtime-host-desktop-candidate.test.ts | 4 +- .../runtime-host-native-capabilities.test.ts | 26 ++- .../session-inspector-panel-model.test.ts | 21 +++ .../main/__tests__/workhub-runtime.test.ts | 2 +- .../main/computer-use-real-model-policy.ts | 5 + .../main/runtime-host-native-capabilities.ts | 16 +- .../acp-session-event-mapper.test.ts | 26 +++ .../mcp-form-host-integration.test.ts | 10 +- .../cli/src/__tests__/pi-transcript.test.ts | 87 +++++++++- ...e-host-capability-provider-command.test.ts | 10 +- .../tui-mcp-local-integration.test.ts | 2 + .../tui-mcp-remote-integration.test.ts | 5 +- packages/cli/src/acp/tool-event-mapper.ts | 24 ++- packages/cli/src/mcp-capability-provider.ts | 1 + packages/cli/src/pi-transcript.ts | 32 ++-- .../tool-result-record-schema.test.ts | 12 ++ packages/core/src/events.ts | 2 + packages/core/src/mcp.ts | 2 + packages/core/src/runtime-event.ts | 7 +- packages/core/src/session-trace.ts | 7 +- packages/core/src/session.ts | 6 + packages/core/src/tool-result-status.ts | 14 ++ .../__tests__/authenticated-websocket.test.ts | 5 +- ...t-capability-admission-integration.test.ts | 10 +- .../client-capability-channel.test.ts | 38 +++-- .../client-capability-coordinator.test.ts | 12 +- ...ient-capability-interaction-broker.test.ts | 10 +- ...lient-capability-invocation-broker.test.ts | 10 +- .../client-capability-protocol.test.ts | 88 ++++++++-- .../client-capability-session-scope.test.ts | 2 +- .../__tests__/client-capability-uds.test.ts | 32 +++- .../__tests__/execution-composition.test.ts | 1 + .../execution-host-continuation.test.ts | 2 +- .../execution-model-composition.test.ts | 3 + .../src/__tests__/oauth-coordinator.test.ts | 2 +- .../__tests__/root-turn-coordinator.test.ts | 10 +- .../session-continuity-coordinator.test.ts | 32 ++++ .../session-transcript-pager.test.ts | 2 + .../src/adapter/session-projector.ts | 4 +- .../src/client/client-capability-channel.ts | 1 + .../src/protocol/client-capability.ts | 13 +- packages/runtime-host/src/protocol/index.ts | 5 +- .../src/protocol/session-continuity.ts | 8 +- .../server/client-capability-coordinator.ts | 3 + .../server/session-continuity-coordinator.ts | 3 +- .../src/server/shared-session-transcript.ts | 1 + .../src/__tests__/computer-use-tools.test.ts | 54 +++++++ .../__tests__/computer-use-wait-for.test.ts | 10 +- .../runtime-event-read-model.test.ts | 30 ++++ .../session-trace-projection.test.ts | 48 ++++++ .../tool-runtime-durable-boundary.test.ts | 88 ++++++++++ .../__tests__/tool-runtime-settlement.test.ts | 36 +++++ packages/runtime/src/computer-use-tools.ts | 150 +++++++++++++++--- packages/runtime/src/mcp-tools.ts | 5 + .../runtime/src/runtime-event-backfill.ts | 1 + .../runtime/src/runtime-event-read-model.ts | 1 + .../src/session-event-runtime-mapper.ts | 1 + .../runtime/src/session-trace-projection.ts | 7 +- packages/runtime/src/tool-runtime.ts | 90 ++++++++--- .../__tests__/sqlite-runtime-store.test.ts | 30 ++++ .../__tests__/live-turn-projection.test.ts | 15 ++ packages/ui/src/__tests__/materialize.test.ts | 89 +++++++++++ packages/ui/src/live-turn-projection.ts | 2 +- packages/ui/src/materialize.ts | 110 +++++++++---- 67 files changed, 1227 insertions(+), 165 deletions(-) diff --git a/apps/desktop/src/main/__tests__/browser-tools.test.ts b/apps/desktop/src/main/__tests__/browser-tools.test.ts index 6ecce3c0e2a..8af1becfe21 100644 --- a/apps/desktop/src/main/__tests__/browser-tools.test.ts +++ b/apps/desktop/src/main/__tests__/browser-tools.test.ts @@ -293,6 +293,7 @@ describe('browser tool execution', () => { ); assert.equal(resolved, 2); assert.deepEqual(result, { + outcome: 'success', content: [ { type: 'text', diff --git a/apps/desktop/src/main/__tests__/mcp-form-host-integration.test.ts b/apps/desktop/src/main/__tests__/mcp-form-host-integration.test.ts index cf797e6aa99..c41897a0b11 100644 --- a/apps/desktop/src/main/__tests__/mcp-form-host-integration.test.ts +++ b/apps/desktop/src/main/__tests__/mcp-form-host-integration.test.ts @@ -49,7 +49,7 @@ for (const action of ['accept', 'decline', 'cancel'] as const) { const answered = await host.answer(pending.requestId, action === 'accept' ? { action, values } : { action }); assert.equal(answered.ok, true, JSON.stringify(answered)); const settled = await result; - assert.deepEqual(settled.result, { content: [{ type: 'text', text: 'complete' }] }); + assert.deepEqual(settled.result, { outcome: 'success', content: [{ type: 'text', text: 'complete' }] }); assert.equal(server.calls.length, 2); assert.notEqual(server.calls[0]?.id, server.calls[1]?.id); assert.deepEqual(server.calls[1]?.params.arguments, server.calls[0]?.params.arguments); @@ -82,7 +82,7 @@ test('Desktop handles a second MCP question as a new canonical Host form', { tim assert.notEqual(second.requestId, first.requestId); assert.equal(server.calls.length, 2); assert.equal((await host.answer(second.requestId, { action: 'accept', values })).ok, true); - assert.deepEqual((await result).result, { content: [{ type: 'text', text: 'complete' }] }); + assert.deepEqual((await result).result, { outcome: 'success', content: [{ type: 'text', text: 'complete' }] }); assert.equal(server.calls.length, 3); assert.deepEqual(server.calls[1]?.params.inputResponses, { form: { action: 'decline' } }); assert.deepEqual(server.calls[2]?.params.inputResponses, { form: { action: 'accept', content: values } }); diff --git a/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts b/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts index 7d14c551ed4..ff5837712e9 100644 --- a/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts +++ b/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts @@ -156,6 +156,7 @@ test('MCP tools stay bound to the connection generation that advertised them', a }, ), { + outcome: 'success', content: [{ type: 'text', text: 'annotated:desktop-capability' }], }, ); @@ -185,6 +186,7 @@ test('MCP tools stay bound to the connection generation that advertised them', a }, ), { + outcome: 'success', content: [ { type: 'text', text: 'desktop-capability' }, { type: 'text', text: '{"structuredContent":{"echoed":"desktop-capability"}}' }, diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts index f27f432c38c..50f08d46019 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts @@ -1016,12 +1016,14 @@ test('refreshes native capabilities with a new immutable provider snapshot', asy }; assert.deepEqual(await host.invokeCapability(frame), { + outcome: 'success', content: [{ type: 'text', text: 'old' }], }); implementation = 'new'; await controls?.refreshClientCapabilities(); assert.equal(host.capabilityRegistrations, 2); assert.deepEqual(await host.invokeCapability(frame), { + outcome: 'success', content: [{ type: 'text', text: 'new' }], }); @@ -1200,7 +1202,7 @@ test('isolates an invalid dynamic MCP tool without dropping the Host connection' serverId: 'desktop_mcp', toolName: 'healthy_mcp', }), - { content: [{ type: 'text', text: 'healthy' }] }, + { outcome: 'success', content: [{ type: 'text', text: 'healthy' }] }, ); await candidate.close(); diff --git a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts index 6bca312df36..6af1ddbf37d 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts @@ -272,7 +272,7 @@ test('forwards JSON Schema native capability arguments to the MCP authority', as arguments: {}, }), ), - { content: [{ type: 'text', text: 'server result' }] }, + { outcome: 'success', content: [{ type: 'text', text: 'server result' }] }, ); assert.deepEqual(receivedArguments, {}); }); @@ -670,7 +670,7 @@ test('validates before admission and invokes the exact offered tool with Host co admitted = true; }, ); - assert.deepEqual(result, { content: [{ type: 'text', text: 'Loaded' }] }); + assert.deepEqual(result, { outcome: 'success', content: [{ type: 'text', text: 'Loaded' }] }); assert.deepEqual(received, { args: { url: 'https://example.com/path' }, context: { @@ -809,6 +809,7 @@ test('projects Computer Use screenshots and releases all native resources for a const completed = await call(provider, computerFrame({ sessionId: 'completed-session', arguments: {} })); assert.deepEqual(completed, { + outcome: 'success', content: [ { type: 'text', text: 'captured' }, { type: 'image', data: 'aW1hZ2U=', mimeType: 'image/png' }, @@ -838,6 +839,19 @@ test('projects Computer Use screenshots and releases all native resources for a await assert.rejects(() => call(provider, capabilityFrame()), /provider is closed/u); }); +test('projects native Computer Use refusal separately from its model text', async () => { + const backend = computerBackend(); + backend.preflight = async () => ({ accessibility: false, screenRecording: true }); + const provider = createDesktopNativeCapabilityProvider({ + browserTools: [], resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, + computerUseTools: buildComputerUseTools({ backend }), releaseDesktopInteractionSession() {}, + }); + const result = await call(provider, computerFrame({ arguments: { action: 'wait', duration: 0.001 } })); + assert.equal(result.outcome, 'error'); + assert.match(result.content[0]?.type === 'text' ? result.content[0].text : '', /permission_missing/); + await provider.close(); +}); + test('does not advertise unavailable capability groups or dispatch unknown identities', async () => { const provider = createDesktopNativeCapabilityProvider({ browserTools: [tool('browser_snapshot', z.object({}), async () => 'ok')], @@ -900,7 +914,7 @@ test('dispatches through the same immutable tool snapshot it advertised', async toolName: 'old_tool', }), ), - { content: [{ type: 'text', text: 'old implementation' }] }, + { outcome: 'success', content: [{ type: 'text', text: 'old implementation' }] }, ); await assert.rejects( () => @@ -965,7 +979,7 @@ test('chunks a dynamic capability group beyond the single-offer tool limit', asy arguments: {}, }), ), - { content: [{ type: 'text', text: 'tool-64' }] }, + { outcome: 'success', content: [{ type: 'text', text: 'tool-64' }] }, ); await provider.close(); }); @@ -1140,7 +1154,7 @@ test('publishes identified tools under their real normalized MCP identity', asyn arguments: {}, }), ), - { content: [{ type: 'text', text: 'echo result' }] }, + { outcome: 'success', content: [{ type: 'text', text: 'echo result' }] }, ); assert.deepEqual( await call( @@ -1152,7 +1166,7 @@ test('publishes identified tools under their real normalized MCP identity', asyn arguments: {}, }), ), - { content: [{ type: 'text', text: 'run result' }] }, + { outcome: 'success', content: [{ type: 'text', text: 'run result' }] }, ); await provider.close(); }); diff --git a/apps/desktop/src/main/__tests__/session-inspector-panel-model.test.ts b/apps/desktop/src/main/__tests__/session-inspector-panel-model.test.ts index 1b39a27bc0c..153208b05ba 100644 --- a/apps/desktop/src/main/__tests__/session-inspector-panel-model.test.ts +++ b/apps/desktop/src/main/__tests__/session-inspector-panel-model.test.ts @@ -194,6 +194,27 @@ test('derives per-turn cost only from priced model-call step totals', () => { } }); +test('renders an interrupted tool as neutral while preserving the turn abort reason', () => { + const trace = traceWithSteps([ + { + kind: 'tool', + id: 'tool-1', + turnId: 'turn-1', + runId: 'run-1', + startedAt: 1, + endedAt: 2, + durationMs: 1, + toolName: 'Read', + status: 'interrupted', + }, + ]); + trace.turns[0]!.failure = { code: 'turn_aborted' }; + + const turn = deriveInspectorPanelModel(trace).turns[0]; + assert.equal(turn?.failureCode, 'turn_aborted'); + assert.equal(turn?.steps[0]?.failed, false); +}); + test('shows one compact diagnostic line for a failed history-compaction call', () => { const trace: SessionTrace = { schemaVersion: SESSION_TRACE_SCHEMA_VERSION, diff --git a/apps/desktop/src/main/__tests__/workhub-runtime.test.ts b/apps/desktop/src/main/__tests__/workhub-runtime.test.ts index e112dc953bc..b4b8cd15e30 100644 --- a/apps/desktop/src/main/__tests__/workhub-runtime.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-runtime.test.ts @@ -225,7 +225,7 @@ test('stop and resume receipts survive the client capability JSON boundary', asy const result = await f.runtime.actTasks(scope, 'turn', 'action', disposition === 'stop_work' ? { operation: 'stop', targetSessionId: 'target' } : { operation: 'resume', targetSessionId: 'target', resumesActionId: 'previous' }); - assert.deepEqual(decodeClientCapabilityResult({ content: [], structuredContent: result }).structuredContent, result); + assert.deepEqual(decodeClientCapabilityResult({ outcome: 'success', content: [], structuredContent: result }).structuredContent, result); assert.equal(Object.hasOwn(result, 'executionEvidence'), false); } }); diff --git a/apps/desktop/src/main/computer-use-real-model-policy.ts b/apps/desktop/src/main/computer-use-real-model-policy.ts index ef396aeceb6..fcf6c7a3b9e 100644 --- a/apps/desktop/src/main/computer-use-real-model-policy.ts +++ b/apps/desktop/src/main/computer-use-real-model-policy.ts @@ -109,12 +109,14 @@ export function applyComputerUseRealModelPolicy( totalActions += 1; if (totalActions > policy.maxTotalActions) { return { + outcome: 'error', text: 'maka_computer failed: total_action_budget_exceeded', error: 'total_action_budget_exceeded', }; } if (!allowed.has(action)) { return { + outcome: 'error', text: `maka_computer.${action} failed: unsupported_action_policy`, error: 'unsupported_action_policy', }; @@ -125,6 +127,7 @@ export function applyComputerUseRealModelPolicy( && (typeof app !== 'string' || !allowedApps.has(app)) ) { return { + outcome: 'error', text: `maka_computer.${action} failed: target_policy_mismatch`, error: 'target_policy_mismatch', }; @@ -142,6 +145,7 @@ export function applyComputerUseRealModelPolicy( ) ) { return { + outcome: 'error', text: `maka_computer.${action} failed: target_policy_mismatch`, error: 'target_policy_mismatch', }; @@ -150,6 +154,7 @@ export function applyComputerUseRealModelPolicy( actionCounts.set(action, actionCount); if (actionCount > (policy.maxActionCounts[action] ?? 0)) { return { + outcome: 'error', text: `maka_computer.${action} failed: action_budget_exceeded`, error: 'action_budget_exceeded', }; diff --git a/apps/desktop/src/main/runtime-host-native-capabilities.ts b/apps/desktop/src/main/runtime-host-native-capabilities.ts index fdaf0fe1299..a68909f3d50 100644 --- a/apps/desktop/src/main/runtime-host-native-capabilities.ts +++ b/apps/desktop/src/main/runtime-host-native-capabilities.ts @@ -20,6 +20,8 @@ import { Buffer } from "node:buffer"; import type { ComputerUseToolSet } from '@maka/runtime/computer-use-tools'; import type { MakaTool } from '@maka/runtime/tool-runtime'; +import { coerceResultContent, deriveToolResultStatus } from '@maka/runtime/tool-runtime'; +import { requireToolCallOutcome } from '@maka/core/tool-result-status'; import { createOAuthPresentationClientProvider, type ClientCapabilityProvider, @@ -730,6 +732,9 @@ async function projectToolResult( input: unknown, output: unknown, ): Promise { + const outcome = tool.resultOutcome + ? requireToolCallOutcome(tool.resultOutcome(output)) + : deriveToolResultStatus(coerceResultContent(output), output); const modelOutput = tool.toModelOutput ? await tool.toModelOutput({ toolCallId, @@ -739,24 +744,25 @@ async function projectToolResult( : undefined; if (!modelOutput) { return typeof output === "string" - ? { content: [{ type: "text", text: output }] } - : { content: [], structuredContent: output }; + ? { outcome, content: [{ type: "text", text: output }] } + : { outcome, content: [], structuredContent: output }; } switch (modelOutput.type) { case "text": case "error-text": - return { content: [{ type: "text", text: modelOutput.value }] }; + return { outcome, content: [{ type: "text", text: modelOutput.value }] }; case "json": case "error-json": - return { content: [], structuredContent: modelOutput.value }; + return { outcome, content: [], structuredContent: modelOutput.value }; case "execution-denied": return { + outcome, content: [ { type: "text", text: modelOutput.reason ?? "Execution denied" }, ], }; case "content": - return { content: modelOutput.value.map(projectContentPart) }; + return { outcome, content: modelOutput.value.map(projectContentPart) }; } } diff --git a/packages/cli/src/__tests__/acp-session-event-mapper.test.ts b/packages/cli/src/__tests__/acp-session-event-mapper.test.ts index b1d1457ab51..c7e707c08d3 100644 --- a/packages/cli/src/__tests__/acp-session-event-mapper.test.ts +++ b/packages/cli/src/__tests__/acp-session-event-mapper.test.ts @@ -355,6 +355,32 @@ describe('ACP Session event mapper', () => { assert.equal(notifications.length, count); }); + test('updates ACP host status when only the explicit outcome changes', async () => { + const notifications: SessionNotification[] = []; + const mapper = eventMapper(notifications); + for (const outcome of ['error', 'aborted'] as const) { + await mapper.accept( + event({ + type: 'tool_result', + toolUseId: 'tool', + contentOmitted: true, + isError: true, + outcome, + content: { kind: 'text', text: '' }, + }), + ); + } + const updates = notifications.map(toolUpdate); + assert.deepEqual( + updates.map((update) => (update._meta?.maka as { hostStatus?: string })?.hostStatus), + ['errored', 'interrupted'], + ); + assert.deepEqual( + updates.map((update) => update.status), + ['failed', 'failed'], + ); + }); + test('result before start creates one terminal card and late start only fills identity', async () => { const notifications: SessionNotification[] = []; const mapper = eventMapper(notifications); diff --git a/packages/cli/src/__tests__/mcp-form-host-integration.test.ts b/packages/cli/src/__tests__/mcp-form-host-integration.test.ts index a8e495f7346..6a076ce7578 100644 --- a/packages/cli/src/__tests__/mcp-form-host-integration.test.ts +++ b/packages/cli/src/__tests__/mcp-form-host-integration.test.ts @@ -56,7 +56,10 @@ for (const action of ['accept', 'decline', 'cancel'] as const) { ); assert.equal(answered.ok, true, JSON.stringify(answered)); const settled = await result; - assert.deepEqual(settled.result, { content: [{ type: 'text', text: 'complete' }] }); + assert.deepEqual(settled.result, { + outcome: 'success', + content: [{ type: 'text', text: 'complete' }], + }); assert.equal(server.calls.length, 2); assert.notEqual(server.calls[0]?.id, server.calls[1]?.id); assert.deepEqual(server.calls[1]?.params.arguments, server.calls[0]?.params.arguments); @@ -92,7 +95,10 @@ test('TUI handles a second MCP question as a new canonical Host form', { assert.notEqual(second.requestId, first.requestId); assert.equal(server.calls.length, 2); assert.equal((await host.answer(second.requestId, { action: 'accept', values })).ok, true); - assert.deepEqual((await result).result, { content: [{ type: 'text', text: 'complete' }] }); + assert.deepEqual((await result).result, { + outcome: 'success', + content: [{ type: 'text', text: 'complete' }], + }); assert.equal(server.calls.length, 3); assert.deepEqual(server.calls[1]?.params.inputResponses, { form: { action: 'decline' } }); assert.deepEqual(server.calls[2]?.params.inputResponses, { diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 5fe26cac94f..f252eb1eb25 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -1418,6 +1418,88 @@ describe('Maka Pi TUI transcript', () => { ); }); + test('scopes stored tool results to their turn when opaque call ids repeat', () => { + const state = createMakaPiTranscriptState(); + replaceTranscriptWithStoredMessages(state, [ + { + type: 'tool_call', + id: 'call-1', + turnId: 'turn-1', + ts: 1, + toolName: 'Read', + args: { path: 'first.txt' }, + }, + { + type: 'tool_result', + id: 'result-1', + turnId: 'turn-1', + ts: 2, + toolUseId: 'call-1', + isError: false, + outcome: 'success', + content: { kind: 'text', text: 'first result' }, + }, + { + type: 'tool_call', + id: 'call-1', + turnId: 'turn-2', + ts: 3, + toolName: 'Read', + args: { path: 'second.txt' }, + }, + { + type: 'tool_result', + id: 'result-2', + turnId: 'turn-2', + ts: 4, + toolUseId: 'call-1', + isError: true, + outcome: 'aborted', + content: { kind: 'text', text: 'second result' }, + }, + ] satisfies StoredMessage[]); + + const tools = state.entries.filter((entry): entry is MakaPiToolEntry => entry.kind === 'tool'); + assert.deepEqual( + tools.map((tool) => ({ + status: toolStatus(tool), + result: tool.result, + })), + [ + { status: 'done', result: { kind: 'text', text: 'first result' } }, + { status: 'aborted', result: { kind: 'text', text: 'second result' } }, + ], + ); + }); + + test('does not attach a retained old result to a reused call id at a history boundary', () => { + const state = createMakaPiTranscriptState(); + replaceTranscriptWithStoredMessages(state, [ + { + type: 'tool_result', + id: 'old-result', + turnId: 'turn-old', + ts: 1, + toolUseId: 'provider-call-1', + isError: true, + outcome: 'error', + content: { kind: 'text', text: 'old failure' }, + }, + { + type: 'tool_call', + id: 'provider-call-1', + turnId: 'turn-new', + ts: 2, + toolName: 'Read', + args: { path: 'new.txt' }, + }, + ] satisfies StoredMessage[]); + + const [tool] = state.entries.filter((entry): entry is MakaPiToolEntry => entry.kind === 'tool'); + assert.equal(tool?.result, undefined); + assert.notEqual(toolStatus(tool), 'errored'); + }); + test('explains a stored tool call whose turn ended without a result', () => { const state = createMakaPiTranscriptState(); replaceTranscriptWithStoredMessages(state, [ @@ -1979,7 +2061,7 @@ describe('Maka Pi TUI transcript', () => { assert.deepEqual(after.slice(0, viewportTop), before.slice(0, viewportTop)); }); - test('replays WriteStdin as a human-readable operation row while merging its PTY revision into Bash', () => { + test('replays a unique cross-turn WriteStdin result while merging its PTY revision into Bash', () => { const state = createMakaPiTranscriptState(); const ref = 'maka://runtime/background-tasks/pty-1'; const rawInput = 'echo hello\r'; @@ -2018,7 +2100,7 @@ describe('Maka Pi TUI transcript', () => { { type: 'tool_result', id: 'write-result', - turnId: 'turn-2', + turnId: 'turn-results', ts: 4, toolUseId: 'write-pty', isError: false, @@ -2054,6 +2136,7 @@ describe('Maka Pi TUI transcript', () => { }, size: { cols: 100, rows: 30 }, }); + assert.equal(toolStatus(tools[1]), 'done'); assert.equal(tools[1]?.durationMs, undefined); refreshRunningShellRunElapsed(state, 3_000); assert.equal(tools[1]?.durationMs, undefined); diff --git a/packages/cli/src/__tests__/runtime-host-capability-provider-command.test.ts b/packages/cli/src/__tests__/runtime-host-capability-provider-command.test.ts index 25fe4e269bf..2650b086030 100644 --- a/packages/cli/src/__tests__/runtime-host-capability-provider-command.test.ts +++ b/packages/cli/src/__tests__/runtime-host-capability-provider-command.test.ts @@ -159,7 +159,10 @@ test('MCP capability publication freezes an accepted callable tool snapshot', as requestInteraction: async () => assert.fail('Unexpected provider interaction'), }, ); - assert.deepEqual(result, { content: [{ type: 'text', text: '{"path":"README.md"}' }] }); + assert.deepEqual(result, { + outcome: 'success', + content: [{ type: 'text', text: '{"path":"README.md"}' }], + }); }); test('MCP capability publication forwards admitted tool progress', async () => { @@ -225,7 +228,10 @@ test('MCP capability publication forwards admitted tool progress', async () => { [1, 3], [3, 3], ]); - assert.deepEqual(result, { content: [{ type: 'text', text: 'done' }] }); + assert.deepEqual(result, { + outcome: 'success', + content: [{ type: 'text', text: 'done' }], + }); }); test('MCP capability publication packs tools across server boundaries', () => { diff --git a/packages/cli/src/__tests__/tui-mcp-local-integration.test.ts b/packages/cli/src/__tests__/tui-mcp-local-integration.test.ts index aab5ec79ffe..438dca61a2b 100644 --- a/packages/cli/src/__tests__/tui-mcp-local-integration.test.ts +++ b/packages/cli/src/__tests__/tui-mcp-local-integration.test.ts @@ -125,6 +125,7 @@ test('local TUI discovers, publishes, invokes, republishes, and closes one MCP c assert.deepEqual( await firstTool.impl({ value: 'before reconnect' }, toolContext(hostRoot, 'call-1')), { + outcome: 'success', content: [{ type: 'text', text: 'before reconnect' }], structuredContent: { echoed: 'before reconnect' }, }, @@ -148,6 +149,7 @@ test('local TUI discovers, publishes, invokes, republishes, and closes one MCP c assert.deepEqual( await replacementTool.impl({ value: 'after reconnect' }, toolContext(hostRoot, 'call-2')), { + outcome: 'success', content: [{ type: 'text', text: 'after reconnect' }], structuredContent: { echoed: 'after reconnect' }, }, diff --git a/packages/cli/src/__tests__/tui-mcp-remote-integration.test.ts b/packages/cli/src/__tests__/tui-mcp-remote-integration.test.ts index b643e24b7b3..6a8ab198600 100644 --- a/packages/cli/src/__tests__/tui-mcp-remote-integration.test.ts +++ b/packages/cli/src/__tests__/tui-mcp-remote-integration.test.ts @@ -428,7 +428,10 @@ function dummyProvider(id: string) { ], }, ], - call: async () => ({ content: [{ type: 'text' as const, text: id }] }), + call: async () => ({ + outcome: 'success' as const, + content: [{ type: 'text' as const, text: id }], + }), }; } diff --git a/packages/cli/src/acp/tool-event-mapper.ts b/packages/cli/src/acp/tool-event-mapper.ts index 27abf8db671..f30fc7fd6dc 100644 --- a/packages/cli/src/acp/tool-event-mapper.ts +++ b/packages/cli/src/acp/tool-event-mapper.ts @@ -33,6 +33,7 @@ import { import type { StoredMessage } from '@maka/core/session'; import { projectToolArgsPreview } from '@maka/core/tool-quiet-preview'; import { toolResultActivityStatus } from '@maka/core/tool-result-status'; +import type { ToolCallOutcome } from '@maka/core/tool-result-status'; import { isCanonicalArtifactEntityId } from '@maka/core/artifacts'; import { formatAttachmentResourceRef } from '@maka/core/attachments'; import type { InteractionPendingSnapshot, InteractionSnapshot } from '@maka/runtime-host/protocol'; @@ -147,6 +148,7 @@ export class AcpToolEventMapper { event.content, event.durationMs, event.contentOmitted === true, + event.outcome, ); return; } @@ -164,6 +166,7 @@ export class AcpToolEventMapper { message.content, message.durationMs, false, + message.outcome, ); } } @@ -288,9 +291,11 @@ export class AcpToolEventMapper { result: ToolResultContent, durationMs: number | undefined, omitted: boolean, + outcome?: ToolCallOutcome, ): Promise { const resultDigest = digestValue({ isError, + outcome, result: omitted ? null : result, durationMs, omitted, @@ -298,7 +303,7 @@ export class AcpToolEventMapper { if (tool.resultDigest === resultDigest) return; if (omitted) tool.resultAnnounced = true; tool.terminal = true; - const hostStatus = toolResultActivityStatus(isError, omitted ? undefined : result); + const hostStatus = toolResultActivityStatus(isError, omitted ? undefined : result, outcome); tool.status = hostStatus === 'completed' ? 'completed' : 'failed'; tool.meta.hostStatus = hostStatus; if (durationMs !== undefined) tool.meta.durationMs = durationMs; @@ -374,7 +379,11 @@ export class AcpToolEventMapper { async #publish( tool: ToolState, - extra: { content?: ToolCallContent[]; rawInput?: unknown; rawOutput?: unknown } = {}, + extra: { + content?: ToolCallContent[]; + rawInput?: unknown; + rawOutput?: unknown; + } = {}, ): Promise { const fixedChars = fixedStateChars(tool); // Keep recent progress and use the remaining per-tool budget for output. @@ -414,7 +423,11 @@ export class AcpToolEventMapper { type: 'text', text: `[${output.stream}]${output.redacted ? ' [redacted]' : ''} ${output.chunk}`, _meta: { - maka: { sequence: output.seq, stream: output.stream, redacted: output.redacted }, + maka: { + sequence: output.seq, + stream: output.stream, + redacted: output.redacted, + }, }, }, }); @@ -521,7 +534,10 @@ function bounded(text: string, max: number, suffix = '…'): { text: string; dro let length = max - suffix.length; const before = text.charCodeAt(length - 1); if (before >= 0xd800 && before <= 0xdbff) length -= 1; - return { text: `${text.slice(0, length)}${suffix}`, dropped: text.length - length }; + return { + text: `${text.slice(0, length)}${suffix}`, + dropped: text.length - length, + }; } function textContent(text: string): ToolCallContent[] { diff --git a/packages/cli/src/mcp-capability-provider.ts b/packages/cli/src/mcp-capability-provider.ts index 0a887c38622..f645d039b59 100644 --- a/packages/cli/src/mcp-capability-provider.ts +++ b/packages/cli/src/mcp-capability-provider.ts @@ -144,6 +144,7 @@ function projectMcpTool(tool: McpToolDescriptor, wireServerId: string) { function projectMcpResult(result: McpCallResult): ClientCapabilityCallResult { return { + outcome: 'success', content: result.content.map((block) => structuredClone(block)), ...(result.structuredContent === undefined ? {} diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 5d5199b222e..4b45d0112ec 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -870,7 +870,7 @@ export function applyMakaSessionEventToTranscript( } if (tool) { if (tool.suppressed) unsuppressToolAtTail(state, tool); - tool.callStatus = toolResultActivityStatus(event.isError, event.content); + tool.callStatus = toolResultActivityStatus(event.isError, event.content, event.outcome); if (shellRun) { if (tool.toolName === 'Bash') { applyShellRunResult(tool, shellRun); @@ -898,7 +898,7 @@ export function applyMakaSessionEventToTranscript( ...(!event.contentOmitted ? { result: event.content } : {}), resultVersion: event.contentOmitted ? 0 : 1, durationMs: event.durationMs, - callStatus: toolResultActivityStatus(event.isError, event.content), + callStatus: toolResultActivityStatus(event.isError, event.content, event.outcome), expanded: state.expandAllTools, }); } @@ -1067,14 +1067,22 @@ function storedMessagesToTranscriptEntries( messages: readonly StoredMessage[], ): MakaPiTranscriptEntry[] { const entries: MakaPiTranscriptEntry[] = []; - const resultsByToolUseId = new Map( - messages - .filter( - (message): message is Extract => - message.type === 'tool_result', - ) - .map((message) => [message.toolUseId, message]), - ); + const latestCallsByUseId = new Map>(); + const resultsByCall = new Map< + Extract, + Extract + >(); + // Append order bounds an opaque ID's ownership: a result belongs to the latest + // preceding call, and a later call with that ID starts a new interval. + for (const message of messages) { + if (message.type === 'tool_call') { + latestCallsByUseId.set(message.id, message); + continue; + } + if (message.type !== 'tool_result') continue; + const call = latestCallsByUseId.get(message.toolUseId); + if (call) resultsByCall.set(call, message); + } const turnStatusById = new Map( deriveTurnRecords(messages).map((turn) => [turn.turnId, turn.status]), ); @@ -1112,7 +1120,7 @@ function storedMessagesToTranscriptEntries( entries.push( storedToolToTranscriptEntry( message, - resultsByToolUseId.get(message.id), + resultsByCall.get(message), turnStatusById.get(message.turnId), ), ); @@ -1149,7 +1157,7 @@ function storedToolToTranscriptEntry( resultVersion: result ? 1 : 0, ...(result?.durationMs !== undefined ? { durationMs: result.durationMs } : {}), callStatus: result - ? toolResultActivityStatus(result.isError, result.content) + ? toolResultActivityStatus(result.isError, result.content, result.outcome) : unfinishedToolActivityStatus(turnStatus), expanded: false, }; diff --git a/packages/core/src/__tests__/tool-result-record-schema.test.ts b/packages/core/src/__tests__/tool-result-record-schema.test.ts index cb0b4be59df..863ba66d16f 100644 --- a/packages/core/src/__tests__/tool-result-record-schema.test.ts +++ b/packages/core/src/__tests__/tool-result-record-schema.test.ts @@ -324,6 +324,18 @@ function storedToolResult(content: unknown) { }; } +test('explicit tool outcome survives both decoders and old rows remain readable', () => { + const legacy = storedToolResult({ kind: 'text', text: 'old' }); + assert.deepEqual(decodePersistedMessage(legacy), legacy); + for (const outcome of ['success', 'error', 'aborted'] as const) { + const row = { ...legacy, isError: outcome !== 'success', outcome }; + assert.deepEqual(decodeCanonicalMessage(row), row); + assert.deepEqual(decodePersistedMessage(row), row); + assert.throws(() => decodeCanonicalMessage({ ...row, isError: outcome === 'success' })); + } + assert.throws(() => decodePersistedMessage({ ...legacy, outcome: 'failed' })); +}); + function decodePersistedMessage(value: unknown): StoredMessage { return decodeStoredMessage(markPersisted(value)); } diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 40e107ebb31..f0f5645a21d 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -873,6 +873,8 @@ export interface ToolResultEvent extends BaseEvent, ToolActivityIdentity { /** The transport omitted durable result content; consumers must not treat the placeholder as authoritative. */ contentOmitted?: true; isError: boolean; + /** Explicit call outcome; absent on legacy and non-migrated tools. */ + outcome?: import('./tool-result-status.js').ToolCallOutcome; content: ToolResultContent; durationMs?: number; } diff --git a/packages/core/src/mcp.ts b/packages/core/src/mcp.ts index dac38f30fa3..e27aae0f972 100644 --- a/packages/core/src/mcp.ts +++ b/packages/core/src/mcp.ts @@ -343,6 +343,8 @@ export type McpContentBlock = export interface McpCallResult { content: McpContentBlock[]; structuredContent?: unknown; + /** Validated Client Capability metadata; ordinary MCP calls do not set it. */ + outcome?: import('./tool-result-status.js').ToolCallOutcome; } export interface McpTestResult { diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index 53e0dcbaa06..3e5ade5b7b0 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -34,6 +34,7 @@ */ import { isWorkHubActionReceipt, type WorkHubActionReceipt } from './workhub-action-result.js'; +import { isToolCallOutcome } from './tool-result-status.js'; import { isModelRetryDecision, type ModelRetryDecision } from './model-failure.js'; import { @@ -222,6 +223,7 @@ export interface RuntimeEventFunctionResponseContent { name: string; result: unknown; isError?: boolean; + outcome?: import('./tool-result-status.js').ToolCallOutcome; providerExecuted?: boolean; /** Raw provider result retained for provider-native replay; never rendered directly. */ providerOutput?: unknown; @@ -752,7 +754,7 @@ const FUNCTION_CALL_CONTENT_SHAPE = defineObjectShape()( ['kind', 'id', 'name', 'result'], - ['isError', 'providerExecuted', 'providerOutput', 'modelProjection'], + ['isError', 'outcome', 'providerExecuted', 'providerOutput', 'modelProjection'], ); const ERROR_CONTENT_SHAPE = defineObjectShape()( ['kind', 'message'], @@ -1108,6 +1110,9 @@ function isRuntimeEventContent(value: unknown): value is RuntimeEventContent { typeof value.name === 'string' && Object.hasOwn(value, 'result') && (value.isError === undefined || typeof value.isError === 'boolean') && + (value.outcome === undefined || + (isToolCallOutcome(value.outcome) && + (value.outcome !== 'success') === (value.isError === true))) && (value.providerExecuted === undefined || typeof value.providerExecuted === 'boolean') && (value.modelProjection === undefined || decodesDurableToolResultProjection(value.modelProjection)) diff --git a/packages/core/src/session-trace.ts b/packages/core/src/session-trace.ts index 71a624d810c..b2f6966cd0c 100644 --- a/packages/core/src/session-trace.ts +++ b/packages/core/src/session-trace.ts @@ -154,7 +154,7 @@ export interface TraceToolStep { toolName: string; toolCallId?: string; operationId?: string; - status: 'completed' | 'failed' | 'in_flight'; + status: 'completed' | 'failed' | 'interrupted' | 'in_flight'; /** * What the dispatch declared it would be safe to do on resume. Present on * normal executions too — it is a policy, not evidence that anything was @@ -538,7 +538,10 @@ function isToolStep(value: Record): boolean { isOptionalNonnegativeNumber(value.endedAt) && isOptionalNonnegativeNumber(value.durationMs) && [value.toolCallId, value.operationId, value.recoveryPolicy].every(isOptionalString) && - (value.status === 'completed' || value.status === 'failed' || value.status === 'in_flight') && + (value.status === 'completed' || + value.status === 'failed' || + value.status === 'interrupted' || + value.status === 'in_flight') && (value.recovered === undefined || isToolRecovery(value.recovered)) ); } diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index ca905c4662e..cf9ad0e0318 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -21,6 +21,7 @@ import type { ExecutorConfiguration } from './executor-catalog.js'; import { isWorkHubActionReceipt, type WorkHubActionReceipt } from './workhub-action-result.js'; import { isExecutorId } from './executor-id.js'; +import { isToolCallOutcome } from './tool-result-status.js'; import { isThinkingLevel, type ThinkingLevel } from './model-thinking.js'; import { @@ -960,6 +961,7 @@ export interface ToolResultMessage { /** Matches ToolCallMessage.id. */ toolUseId: string; isError: boolean; + outcome?: import('./tool-result-status.js').ToolCallOutcome; content: ToolResultContent; providerExecuted?: boolean; /** Raw provider result retained only for provider-native replay. */ @@ -1396,6 +1398,7 @@ const TOOL_RESULT_MESSAGE_SHAPE = defineObjectShape()( ['type', 'id', 'turnId', 'ts', 'toolUseId', 'isError', 'content'], [ 'durationMs', + 'outcome', 'providerExecuted', 'providerOutput', 'origin', @@ -1699,6 +1702,9 @@ function decodeMessage( hasMessageEnvelope(message, true) && typeof message.toolUseId === 'string' && typeof message.isError === 'boolean' && + (message.outcome === undefined || + (isToolCallOutcome(message.outcome) && + (message.outcome !== 'success') === message.isError)) && (message.providerExecuted === undefined || typeof message.providerExecuted === 'boolean') && isOptionalFiniteDuration(message.durationMs) && isToolActivityIdentity(message) diff --git a/packages/core/src/tool-result-status.ts b/packages/core/src/tool-result-status.ts index 5c06c9404d2..345354c7c7a 100644 --- a/packages/core/src/tool-result-status.ts +++ b/packages/core/src/tool-result-status.ts @@ -25,6 +25,16 @@ import type { ToolResultContent } from './events.js'; import type { TurnStatus } from './session.js'; export type SettledToolActivityStatus = 'completed' | 'errored' | 'interrupted'; +export type ToolCallOutcome = 'success' | 'error' | 'aborted'; + +export function isToolCallOutcome(value: unknown): value is ToolCallOutcome { + return value === 'success' || value === 'error' || value === 'aborted'; +} + +export function requireToolCallOutcome(value: unknown): ToolCallOutcome { + if (!isToolCallOutcome(value)) throw new Error('Invalid tool call outcome'); + return value; +} /** A call that has started and has not settled. */ export type InFlightToolActivityStatus = 'running'; @@ -79,7 +89,11 @@ export function isCancelledToolResultContent(content: ToolResultContent | undefi export function toolResultActivityStatus( isError: boolean, content: ToolResultContent | undefined, + outcome?: ToolCallOutcome, ): SettledToolActivityStatus { + if (outcome !== undefined) { + return outcome === 'success' ? 'completed' : outcome === 'aborted' ? 'interrupted' : 'errored'; + } if (!isError) return 'completed'; // Failed cancel (user stop / kill) — not a tool failure banner. if (isCancelledToolResultContent(content)) return 'interrupted'; diff --git a/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts b/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts index 22316b809b1..1b343b035b4 100644 --- a/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts +++ b/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts @@ -964,7 +964,10 @@ test('an unbound WebSocket credential cannot claim an existing bound Client iden ], }, ], - call: async () => ({ content: [{ type: 'text', text: 'bound' }] }), + call: async () => ({ + outcome: 'success', + content: [{ type: 'text', text: 'bound' }], + }), }); assert.deepEqual( diff --git a/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts b/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts index 36d69577d59..fae0afe5f9b 100644 --- a/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts @@ -107,7 +107,10 @@ test('cancels managed approval owners and joiners with the canonical provider id connection.accept({ kind: 'client.capability.result', invocationId: frame.invocationId, - result: { content: [{ type: 'text', text: 'snapshot' }] }, + result: { + outcome: 'success', + content: [{ type: 'text', text: 'snapshot' }], + }, }); } }, @@ -257,7 +260,10 @@ test('cancels managed approval owners and joiners with the canonical provider id ...retryRequest.request.target, }); assert.equal(grant?.providerId, providerId); - assert.deepEqual(settlement.result, { content: [{ type: 'text', text: 'snapshot' }] }); + assert.deepEqual(settlement.result, { + outcome: 'success', + content: [{ type: 'text', text: 'snapshot' }], + }); assert.deepEqual(order, ['accepted', 'accepted', 'accepted', 'approved', 'T1', 'admitted']); } finally { snapshot?.release(); diff --git a/packages/runtime-host/src/__tests__/client-capability-channel.test.ts b/packages/runtime-host/src/__tests__/client-capability-channel.test.ts index 81e3c2b28d3..12a902fb03d 100644 --- a/packages/runtime-host/src/__tests__/client-capability-channel.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-channel.test.ts @@ -45,7 +45,7 @@ test('Session registrations mutate independently and reject reverse calls for an ], call: async () => { providerCalls += 1; - return { content: [] }; + return { outcome: 'success', content: [] }; }, }; const channel = new ClientCapabilityChannel({ @@ -112,7 +112,7 @@ test('Client Capability channel closes a provider after its final registration i ], }, ], - call: async () => ({ content: [] }), + call: async () => ({ outcome: 'success', content: [] }), close: () => { closeCalls += 1; }, @@ -269,7 +269,11 @@ test('Client Capability channel runs a self-described Host service through admis { kind: 'client.capability.result', invocationId: 'service_invocation', - result: { content: [], structuredContent: { kind: 'presented' } }, + result: { + outcome: 'success', + content: [], + structuredContent: { kind: 'presented' }, + }, }, ]); channel.close(new Error('test complete')); @@ -298,7 +302,7 @@ test('Client Capability channel rejects Host paths before invoking a path-isolat ], call: async () => { callCount += 1; - return { content: [] }; + return { outcome: 'success', content: [] }; }, }; const channel = new ClientCapabilityChannel({ @@ -364,7 +368,7 @@ test('Client Capability channel forwards admitted tool progress before the resul options.progress?.(3, 3); options.progress?.(2, 3); options.progress?.(1, 1_025); - return { content: [] }; + return { outcome: 'success', content: [] }; }, }; channel = new ClientCapabilityChannel({ @@ -426,7 +430,7 @@ test('Client Capability channel forwards admitted tool progress before the resul { kind: 'client.capability.result', invocationId: 'progress-invocation', - result: { content: [] }, + result: { outcome: 'success', content: [] }, }, ]); channel.close(new Error('test complete')); @@ -465,8 +469,14 @@ test('Client Capability channel correlates one admitted nested form before the f }, ], }); - assert.deepEqual(answer, { action: 'accept', values: { target: 'staging' } }); - return { content: [{ type: 'text', text: 'deployed' }] }; + assert.deepEqual(answer, { + action: 'accept', + values: { target: 'staging' }, + }); + return { + outcome: 'success', + content: [{ type: 'text', text: 'deployed' }], + }; }, }; channel = new ClientCapabilityChannel({ @@ -526,9 +536,15 @@ test('Client Capability channel correlates one admitted nested form before the f assert.deepEqual(written.at(-1), { kind: 'client.capability.result', invocationId: 'nested-form', - result: { content: [{ type: 'text', text: 'deployed' }] }, + result: { + outcome: 'success', + content: [{ type: 'text', text: 'deployed' }], + }, + }); + channel.accept({ + kind: 'client.capability.release', + invocationId: 'nested-form', }); - channel.accept({ kind: 'client.capability.release', invocationId: 'nested-form' }); channel.close(new Error('test complete')); }); @@ -559,7 +575,7 @@ test('Client Capability release rejects a pending nested form', async () => { requester: { name: 'deploy' }, fields: [{ kind: 'boolean', name: 'confirm', label: 'Confirm', required: true }], }); - return { content: [] }; + return { outcome: 'success', content: [] }; } catch (error) { observedError = error; throw error; diff --git a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts index 06aae43035d..e7426666438 100644 --- a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts @@ -22,7 +22,7 @@ import { describe, test } from 'node:test'; import { createManagedExecutionBoundary } from '@maka/core/sandbox-boundary'; import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; import { ToolOutcomeUnknownError } from '@maka/core/events'; -import type { McpCallResult } from '@maka/core/mcp'; +import type { ClientCapabilityCallResult } from '../protocol/index.js'; import type { ClientCapabilityAdmissionEvidence, ClientCapabilityCallFrame, @@ -2383,7 +2383,11 @@ test('Host services stay bound to the explicitly initiating Client connection', connection.accept({ kind: 'client.capability.result', invocationId: frame.invocationId, - result: { content: [], structuredContent: { kind: 'presented' } }, + result: { + outcome: 'success', + content: [], + structuredContent: { kind: 'presented' }, + }, }); } }, @@ -2633,8 +2637,8 @@ function toolAt( return snapshot?.tools[index]; } -function textResult(text: string): McpCallResult { - return { content: [{ type: 'text', text }] }; +function textResult(text: string): ClientCapabilityCallResult { + return { outcome: 'success', content: [{ type: 'text', text }] }; } function isRecord(value: unknown): value is Record { diff --git a/packages/runtime-host/src/__tests__/client-capability-interaction-broker.test.ts b/packages/runtime-host/src/__tests__/client-capability-interaction-broker.test.ts index 31e23f4d73f..6166737bf3c 100644 --- a/packages/runtime-host/src/__tests__/client-capability-interaction-broker.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-interaction-broker.test.ts @@ -123,7 +123,10 @@ test('Client Capability accepts a final result while interaction delivery is sti broker.accept('connection-a', { kind: 'client.capability.result', invocationId: frame.invocationId, - result: { content: [{ type: 'text', text: 'deployed' }] }, + result: { + outcome: 'success', + content: [{ type: 'text', text: 'deployed' }], + }, }); await flush(); }, @@ -160,7 +163,10 @@ test('Client Capability accepts a final result while interaction delivery is sti }, }); - assert.deepEqual(await result, { content: [{ type: 'text', text: 'deployed' }] }); + assert.deepEqual(await result, { + outcome: 'success', + content: [{ type: 'text', text: 'deployed' }], + }); broker.close(); }); diff --git a/packages/runtime-host/src/__tests__/client-capability-invocation-broker.test.ts b/packages/runtime-host/src/__tests__/client-capability-invocation-broker.test.ts index 01bab875c9b..c74297c0b5c 100644 --- a/packages/runtime-host/src/__tests__/client-capability-invocation-broker.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-invocation-broker.test.ts @@ -71,7 +71,10 @@ describe('ClientCapabilityInvocationBroker', () => { broker.accept('connection-a', { kind: 'client.capability.result', invocationId: frame.invocationId, - result: { content: [{ type: 'text', text: 'ok' }] }, + result: { + outcome: 'success', + content: [{ type: 'text', text: 'ok' }], + }, }), ); } @@ -88,7 +91,10 @@ describe('ClientCapabilityInvocationBroker', () => { false, ); - assert.deepEqual(await prepared.admit(), { content: [{ type: 'text', text: 'ok' }] }); + assert.deepEqual(await prepared.admit(), { + outcome: 'success', + content: [{ type: 'text', text: 'ok' }], + }); assert.equal(sent.filter((frame) => frame.kind === 'client.capability.admitted').length, 1); broker.close(); }); diff --git a/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts b/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts index 22f16a6d5a6..2ac0d41abe9 100644 --- a/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts @@ -32,6 +32,37 @@ import { } from '../protocol/index.js'; describe('Client Capability protocol', () => { + test('requires a tri-state result outside business structuredContent', () => { + for (const outcome of ['success', 'error', 'aborted'] as const) { + assert.deepEqual( + decodeClientCapabilityResult({ + outcome, + content: [], + structuredContent: { outcome: 'business' }, + }), + { + outcome, + content: [], + structuredContent: { outcome: 'business' }, + }, + ); + } + for (const outcome of [undefined, null, true, 'failed', 'SUCCESS']) { + assert.throws( + () => decodeClientCapabilityResult({ outcome, content: [] }), + RuntimeHostProtocolError, + ); + } + assert.throws( + () => + decodeClientCapabilityResult({ + content: [], + structuredContent: { outcome: 'error' }, + }), + RuntimeHostProtocolError, + ); + }); + test('validates complete Session configuration identities', () => { const input = { registrationId: 'registration', @@ -48,7 +79,11 @@ describe('Client Capability protocol', () => { `sha256:${'z'.repeat(64)}`, ]) { assert.throws( - () => decodeClientCapabilityReplaceInput({ ...input, sessionConfigurationId }), + () => + decodeClientCapabilityReplaceInput({ + ...input, + sessionConfigurationId, + }), RuntimeHostProtocolError, ); } @@ -97,7 +132,10 @@ describe('Client Capability protocol', () => { turnId: 'turn', }; for (const toolCallId of ['call:outer:nested:inner', 'provider/call.1+part', 'x'.repeat(256)]) { - assert.deepEqual(decodeHostFrame({ ...frame, toolCallId }), { ...frame, toolCallId }); + assert.deepEqual(decodeHostFrame({ ...frame, toolCallId }), { + ...frame, + toolCallId, + }); } for (const toolCallId of [ undefined, @@ -116,7 +154,11 @@ describe('Client Capability protocol', () => { for (const field of ['invocationId', 'registrationId', 'offerId', 'sessionId', 'turnId']) { assert.throws( () => - decodeHostFrame({ ...frame, toolCallId: 'call:nested:inner', [field]: 'entity:invalid' }), + decodeHostFrame({ + ...frame, + toolCallId: 'call:nested:inner', + [field]: 'entity:invalid', + }), RuntimeHostProtocolError, ); } @@ -209,12 +251,18 @@ describe('Client Capability protocol', () => { decodeClientFrame({ kind: 'client.capability.accepted', invocationId: 'invocation', - admissionEvidence: { kind: 'browser_url', url: 'https://example.com/path' }, + admissionEvidence: { + kind: 'browser_url', + url: 'https://example.com/path', + }, }), { kind: 'client.capability.accepted', invocationId: 'invocation', - admissionEvidence: { kind: 'browser_url', url: 'https://example.com/path' }, + admissionEvidence: { + kind: 'browser_url', + url: 'https://example.com/path', + }, }, ); assert.deepEqual( @@ -299,7 +347,12 @@ describe('Client Capability protocol', () => { requester: { name: 'fixture' }, fields: [ { kind: 'boolean', name: 'same', label: 'First', required: true }, - { kind: 'boolean', name: 'same', label: 'Second', required: true }, + { + kind: 'boolean', + name: 'same', + label: 'Second', + required: true, + }, ], }, }), @@ -325,7 +378,12 @@ describe('Client Capability protocol', () => { input: { registrationId: 'registration', offers: [], - services: [{ serviceId: SCHEDULED_TASK_NATIVE_EFFECT_SERVICE_ID, version: 'vendor-v4' }], + services: [ + { + serviceId: SCHEDULED_TASK_NATIVE_EFFECT_SERVICE_ID, + version: 'vendor-v4', + }, + ], }, }), { @@ -334,7 +392,12 @@ describe('Client Capability protocol', () => { input: { registrationId: 'registration', offers: [], - services: [{ serviceId: SCHEDULED_TASK_NATIVE_EFFECT_SERVICE_ID, version: 'vendor-v4' }], + services: [ + { + serviceId: SCHEDULED_TASK_NATIVE_EFFECT_SERVICE_ID, + version: 'vendor-v4', + }, + ], }, }, ); @@ -458,7 +521,10 @@ describe('Client Capability protocol', () => { for (const inputSchema of [ { type: 'string' }, { type: 'object', unsupportedKeyword: true }, - { type: 'object', properties: { value: { $ref: 'https://example.test/schema' } } }, + { + type: 'object', + properties: { value: { $ref: 'https://example.test/schema' } }, + }, { type: 'object', properties: { value: { $ref: '#/$defs/missing' } } }, ]) { assert.throws( @@ -672,6 +738,7 @@ describe('Client Capability protocol', () => { test('rejects non-canonical media data and invalid image MIME types', () => { assert.deepEqual( decodeClientCapabilityResult({ + outcome: 'success', content: [ { type: 'image', data: 'aGVsbG8=', mimeType: 'image/png' }, { type: 'audio', data: 'YQ==', mimeType: 'audio/wav' }, @@ -679,6 +746,7 @@ describe('Client Capability protocol', () => { ], }), { + outcome: 'success', content: [ { type: 'image', data: 'aGVsbG8=', mimeType: 'image/png' }, { type: 'audio', data: 'YQ==', mimeType: 'audio/wav' }, @@ -696,7 +764,7 @@ describe('Client Capability protocol', () => { [{ type: 'image', data: 'YQ==', mimeType: 'image/png; charset=binary' }], ]) { assert.throws( - () => decodeClientCapabilityResult({ content }), + () => decodeClientCapabilityResult({ outcome: 'success', content }), (error: unknown) => error instanceof RuntimeHostProtocolError, ); } diff --git a/packages/runtime-host/src/__tests__/client-capability-session-scope.test.ts b/packages/runtime-host/src/__tests__/client-capability-session-scope.test.ts index f9f63375b7b..7549304e505 100644 --- a/packages/runtime-host/src/__tests__/client-capability-session-scope.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-session-scope.test.ts @@ -575,5 +575,5 @@ function context(connectionId: string) { } function result(text: string) { - return { content: [{ type: 'text' as const, text }] }; + return { outcome: 'success' as const, content: [{ type: 'text' as const, text }] }; } diff --git a/packages/runtime-host/src/__tests__/client-capability-uds.test.ts b/packages/runtime-host/src/__tests__/client-capability-uds.test.ts index 468da0389d6..3b3ecd7d09c 100644 --- a/packages/runtime-host/src/__tests__/client-capability-uds.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-uds.test.ts @@ -170,6 +170,12 @@ test('unknown Client Capability loads, invokes, and rebinds after UDS reconnect' } await accept({ kind: 'none' }); return { + outcome: + frame.arguments.prefix === 'failure' + ? 'error' + : frame.arguments.prefix === 'aborted' + ? 'aborted' + : 'success', content: [ { type: 'text', @@ -240,8 +246,19 @@ test('unknown Client Capability loads, invokes, and rebinds after UDS reconnect' const result = await tool.impl({ prefix: 'from-uds' }, toolContext); assert.deepEqual(result, { + outcome: 'success', content: [{ type: 'text', text: `from-uds:${largeValue}` }], }); + const failed = await tool.impl({ prefix: 'failure' }, toolContext); + assert.deepEqual(failed, { + outcome: 'error', + content: [{ type: 'text', text: `failure:${largeValue}` }], + }); + const aborted = await tool.impl({ prefix: 'aborted' }, toolContext); + assert.deepEqual(aborted, { + outcome: 'aborted', + content: [{ type: 'text', text: `aborted:${largeValue}` }], + }); await client.status(); await assert.rejects( async () => rejectedTool.impl({}, toolContext), @@ -264,7 +281,7 @@ test('unknown Client Capability loads, invokes, and rebinds after UDS reconnect' call: async (frame, options) => { assert.equal(frame.sessionId, sessionId); await options.accept({ kind: 'none' }); - return { content: [{ type: 'text', text: value }] }; + return { outcome: 'success', content: [{ type: 'text', text: value }] }; }, }); await Promise.all([ @@ -291,19 +308,23 @@ test('unknown Client Capability loads, invokes, and rebinds after UDS reconnect' } }; assert.deepEqual(await invokeScoped('scope-a'), { + outcome: 'success', content: [{ type: 'text', text: 'from-a' }], }); assert.deepEqual(await invokeScoped('scope-b'), { + outcome: 'success', content: [{ type: 'text', text: 'from-b' }], }); await client.replaceClientCapabilities(scopedProvider('scope-a', 'replacement-a'), { sessionId: 'scope-a', }); assert.deepEqual(await invokeScoped('scope-a'), { + outcome: 'success', content: [{ type: 'text', text: 'replacement-a' }], }); await client.unregisterClientCapabilities({ sessionId: 'scope-a' }); assert.deepEqual(await invokeScoped('scope-b'), { + outcome: 'success', content: [{ type: 'text', text: 'from-b' }], }); await client.unregisterClientCapabilities({ sessionId: 'scope-b' }); @@ -336,7 +357,13 @@ test('unknown Client Capability loads, invokes, and rebinds after UDS reconnect' assert.equal(frame.toolCallId, toolCallId); await accept({ kind: 'none' }); return { - content: [{ type: 'text', text: `reconnected:${String(frame.arguments.prefix)}` }], + outcome: 'success', + content: [ + { + type: 'text', + text: `reconnected:${String(frame.arguments.prefix)}`, + }, + ], }; }, }); @@ -350,6 +377,7 @@ test('unknown Client Capability loads, invokes, and rebinds after UDS reconnect' ); assert.ok(reconnectedTool); assert.deepEqual(await reconnectedTool.impl({ prefix: 'from-uds' }, toolContext), { + outcome: 'success', content: [{ type: 'text', text: 'reconnected:from-uds' }], }); } finally { diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index 67d7a20b3a1..501ae0b66ba 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -1782,6 +1782,7 @@ test('production WorkHub inspects an independent Session through its provider to kind: 'client.capability.result', invocationId: frame.invocationId, result: { + outcome: 'success', content: [], structuredContent: { operation: 'associate', diff --git a/packages/runtime-host/src/__tests__/execution-host-continuation.test.ts b/packages/runtime-host/src/__tests__/execution-host-continuation.test.ts index 9c54e88422d..440758b83c5 100644 --- a/packages/runtime-host/src/__tests__/execution-host-continuation.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host-continuation.test.ts @@ -455,7 +455,7 @@ function resumeFixtureProvider( ], call: async (_frame, { accept }) => { await accept({ kind: 'none' }); - return { content: [{ type: 'text', text: 'ok' }] }; + return { outcome: 'success', content: [{ type: 'text', text: 'ok' }] }; }, }; } diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index 5bfcbdd9697..97f85589b5a 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -653,6 +653,7 @@ async function runPermissionUpdateHostRegression( kind: 'client.capability.result', invocationId: frame.invocationId, result: { + outcome: 'success', content: [{ type: 'text', text: CLIENT_CAPABILITY_RESULT_TEXT }], }, }); @@ -1988,6 +1989,7 @@ test('production backend preserves coordinator Client Capability semantics acros kind: 'client.capability.result', invocationId: frame.invocationId, result: { + outcome: 'success', content: [{ type: 'text', text: CLIENT_CAPABILITY_RESULT_TEXT }], }, }); @@ -2223,6 +2225,7 @@ test('cold WorkHub recovery waits for Desktop tools across pending-message and a kind: 'client.capability.result', invocationId: frame.invocationId, result: { + outcome: 'success', content: [], structuredContent: { operation: 'associate', diff --git a/packages/runtime-host/src/__tests__/oauth-coordinator.test.ts b/packages/runtime-host/src/__tests__/oauth-coordinator.test.ts index e8aca9060f6..18b1918685f 100644 --- a/packages/runtime-host/src/__tests__/oauth-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/oauth-coordinator.test.ts @@ -1181,7 +1181,7 @@ async function attachPresentation( connection.accept({ kind: 'client.capability.result', invocationId: frame.invocationId, - result: { content: [], structuredContent }, + result: { outcome: 'success', content: [], structuredContent }, }); }, }); diff --git a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts index 1501ab6304d..eb7a17472ef 100644 --- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts @@ -4899,7 +4899,10 @@ async function assertSessionSuccessorCapabilityDegradation( previousProvider.accept({ kind: 'client.capability.result', invocationId: frame.invocationId, - result: { content: [{ type: 'text', text: 'previous' }] }, + result: { + outcome: 'success', + content: [{ type: 'text', text: 'previous' }], + }, }); }, }, @@ -4919,7 +4922,10 @@ async function assertSessionSuccessorCapabilityDegradation( followupProvider.accept({ kind: 'client.capability.result', invocationId: frame.invocationId, - result: { content: [{ type: 'text', text: 'followup' }] }, + result: { + outcome: 'success', + content: [{ type: 'text', text: 'followup' }], + }, }); }, }, diff --git a/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts index d1fd3492a09..fdc53f2fe8a 100644 --- a/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts @@ -2191,6 +2191,38 @@ test('tool_result clears retained tool_result_preview so a later open does not s coordinator.close(); }); +test('an aborted result remains interrupted when continuity omits its content', async () => { + const coordinator = new SessionContinuityCoordinator( + HOST_EPOCH, + async () => canonical(), + new SessionAdmissionGate(), + ); + const sink = new RecordingSink(); + const connection = attachTestConnection(coordinator, 'connection-aborted-result', sink); + const opened = await open(coordinator, 'connection-aborted-result'); + connection.activate(opened.subscriptionId); + await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', { + type: 'tool_result', + id: 'result-1', + turnId: 'turn-1', + ts: 2, + toolUseId: 'tool-1', + isError: true, + outcome: 'aborted', + content: { kind: 'text', text: 'private result' }, + }); + await waitFor(() => sink.frames.length === 1); + const frame = sink.frames[0]; + assert.equal( + frame?.kind === 'subscription.session_event' && + frame.event.type === 'tool_result' && + frame.event.status, + 'interrupted', + ); + connection.abort(opened.subscriptionId); + coordinator.close(); +}); + test('publishes only the minimal sandbox failure reason from a tool result', async () => { const coordinator = new SessionContinuityCoordinator( HOST_EPOCH, diff --git a/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts b/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts index b46a0f03eb7..8a5c0516fc7 100644 --- a/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts +++ b/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts @@ -141,6 +141,7 @@ test('projects durable transcript records before sharing them', async () => { ts: 2, toolUseId: 'tool-1', isError: false, + outcome: 'aborted', content: { kind: 'text', text: 'visible result' }, modelVisibility: 'hidden', providerOutput: { replay: 'private' }, @@ -216,6 +217,7 @@ test('projects durable transcript records before sharing them', async () => { assert.equal('displayText' in sharedDurable[0]!, false); assert.equal(sharedDurable[0]?.steeringEventId, 'steering-event-1'); assert.equal('providerOutput' in sharedDurable[1]!, false); + assert.equal(sharedDurable[1]?.outcome, 'aborted'); assert.equal('providerOptions' in sharedDurable[2]!, false); assert.deepEqual(sharedDurable[2]!.thinking, { text: 'visible thought' }); const projectedState = projectSharedSessionTranscriptMessage( diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts index 22b1b2a1e72..c36d07e32a1 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -699,7 +699,9 @@ function projectSessionEvent( type: 'tool_result', ...base, contentOmitted: true, - isError: event.status === 'errored', + isError: event.status !== 'completed', + outcome: + event.status === 'interrupted' ? 'aborted' : event.status === 'errored' ? 'error' : 'success', content: { kind: 'text', text: '', diff --git a/packages/runtime-host/src/client/client-capability-channel.ts b/packages/runtime-host/src/client/client-capability-channel.ts index 0ab0f4a16cb..61248d0e620 100644 --- a/packages/runtime-host/src/client/client-capability-channel.ts +++ b/packages/runtime-host/src/client/client-capability-channel.ts @@ -336,6 +336,7 @@ export class ClientCapabilityChannel { this.#invocations.set(frame.invocationId, invocation); void this.#runInvocation(frame.invocationId, invocation, async (options) => decodeClientCapabilityResult({ + outcome: 'success', content: [], structuredContent: await registration.provider.callService!(frame, options), }), diff --git a/packages/runtime-host/src/protocol/client-capability.ts b/packages/runtime-host/src/protocol/client-capability.ts index 49f3522ff86..85253c8b1ab 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -18,6 +18,7 @@ */ import { TOOL_ACTIVITY_KINDS, type ToolActivityKind } from '@maka/core/events'; +import { isToolCallOutcome, type ToolCallOutcome } from '@maka/core/tool-result-status'; import { decodeInteractionAnswer, projectInteractionFormRequest, @@ -74,6 +75,7 @@ export type ClientCapabilityContentBlock = | { readonly type: 'unknown'; readonly value: unknown }; export interface ClientCapabilityCallResult { + readonly outcome: ToolCallOutcome; readonly content: ClientCapabilityContentBlock[]; readonly structuredContent?: unknown; } @@ -693,12 +695,21 @@ export function decodeClientCapabilityHostFrame(value: unknown): ClientCapabilit export function decodeClientCapabilityResult(value: unknown): ClientCapabilityCallResult { const record = requireRecord(value, 'Client Capability result'); - assertOptionalExactKeys(record, 'Client Capability result', ['content'], ['structuredContent']); + assertOptionalExactKeys( + record, + 'Client Capability result', + ['outcome', 'content'], + ['structuredContent'], + ); + if (!isToolCallOutcome(record.outcome)) { + throw invalidProtocolFrame('Invalid Client Capability result outcome'); + } if (!Array.isArray(record.content) || record.content.length > 256) { throw invalidProtocolFrame('Invalid Client Capability result content'); } const content = record.content.map(decodeContentBlock); return { + outcome: record.outcome, content, ...(Object.hasOwn(record, 'structuredContent') ? { diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 1f365745e13..a2b5157ce9f 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -106,7 +106,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 219 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 220 as const; +// 220: Client Capability results require a tri-state outcome; session tool +// continuity and trace steps carry interrupted results. Older peers cannot +// decode the changed strict shapes. // 219: Host change feed includes Artifact deletion and Session purge invalidation frames; // the Artifact invalidation feed and guest routing require matching peers. // 217: token_usage.lastRequestAnchor may carry completedAt, the provider diff --git a/packages/runtime-host/src/protocol/session-continuity.ts b/packages/runtime-host/src/protocol/session-continuity.ts index f754a0a2ca6..48b0c20bbf8 100644 --- a/packages/runtime-host/src/protocol/session-continuity.ts +++ b/packages/runtime-host/src/protocol/session-continuity.ts @@ -208,7 +208,7 @@ export type SessionToolEvent = | (SessionToolEventIdentity & { type: 'tool_result'; operationId?: string; - status: 'completed' | 'errored'; + status: 'completed' | 'errored' | 'interrupted'; sandboxFailureReason?: SandboxBoundaryFailureSignal['reason']; durationMs?: number; }) @@ -1003,7 +1003,11 @@ function decodeSessionToolEvent(value: unknown): SessionToolEvent { 'toolUseId', 'status', ]); - if (record.status !== 'completed' && record.status !== 'errored') { + if ( + record.status !== 'completed' && + record.status !== 'errored' && + record.status !== 'interrupted' + ) { throw invalidProtocolFrame('Invalid Session tool result status'); } if (record.status === 'completed' && record.sandboxFailureReason !== undefined) { diff --git a/packages/runtime-host/src/server/client-capability-coordinator.ts b/packages/runtime-host/src/server/client-capability-coordinator.ts index a721cdc4dd5..e5ca3e3e423 100644 --- a/packages/runtime-host/src/server/client-capability-coordinator.ts +++ b/packages/runtime-host/src/server/client-capability-coordinator.ts @@ -26,6 +26,7 @@ import { type McpToolProvider, } from '@maka/runtime/mcp-tools'; import { type MakaTool } from '@maka/runtime/tool-runtime'; +import { requireToolCallOutcome } from '@maka/core/tool-result-status'; import type { RootExecutionDescriptor } from '@maka/core/runtime-invocation'; import { clientCapabilityScopeIdentity, @@ -696,6 +697,7 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService assertUniqueSnapshotToolIdentities(selected); const tools = [ ...buildMcpTools(this.#snapshotProvider(state?.initiatingProviderId, interactive), { + resultOutcome: (output) => requireToolCallOutcome(output.outcome), callTimeoutMs: DEFAULT_CALL_TIMEOUT_MS, categoryHint: 'client_capability', hostAdmission: 'client_capability', @@ -703,6 +705,7 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService executionLocation: 'remote', }), ...buildMcpTools(this.#snapshotProvider(state?.initiatingProviderId, trusted), { + resultOutcome: (output) => requireToolCallOutcome(output.outcome), callTimeoutMs: DEFAULT_CALL_TIMEOUT_MS, categoryHint: 'custom_tool', hostAdmission: 'client_capability', diff --git a/packages/runtime-host/src/server/session-continuity-coordinator.ts b/packages/runtime-host/src/server/session-continuity-coordinator.ts index a9b6c45a6b3..628fc6c09bb 100644 --- a/packages/runtime-host/src/server/session-continuity-coordinator.ts +++ b/packages/runtime-host/src/server/session-continuity-coordinator.ts @@ -2091,7 +2091,8 @@ function projectSessionEvent( type: event.type, ...identity, ...(event.operationId === undefined ? {} : { operationId: event.operationId }), - status: event.isError ? 'errored' : 'completed', + status: + event.outcome === 'aborted' ? 'interrupted' : event.isError ? 'errored' : 'completed', ...(event.isError && event.content.kind === 'text' && event.content.sandboxFailure ? { sandboxFailureReason: event.content.sandboxFailure.reason } : {}), diff --git a/packages/runtime-host/src/server/shared-session-transcript.ts b/packages/runtime-host/src/server/shared-session-transcript.ts index 243512859f4..ced768c7e65 100644 --- a/packages/runtime-host/src/server/shared-session-transcript.ts +++ b/packages/runtime-host/src/server/shared-session-transcript.ts @@ -108,6 +108,7 @@ export function projectSharedSessionTranscriptMessage( ts: message.ts, toolUseId: message.toolUseId, isError: message.isError, + ...(message.outcome === undefined ? {} : { outcome: message.outcome }), content: message.content, ...(message.durationMs === undefined ? {} : { durationMs: message.durationMs }), ...(message.origin === undefined ? {} : { origin: message.origin }), diff --git a/packages/runtime/src/__tests__/computer-use-tools.test.ts b/packages/runtime/src/__tests__/computer-use-tools.test.ts index c79f2c48453..b6d876e5e9a 100644 --- a/packages/runtime/src/__tests__/computer-use-tools.test.ts +++ b/packages/runtime/src/__tests__/computer-use-tools.test.ts @@ -1318,6 +1318,57 @@ describe('buildComputerUseTools — the `maka_computer` MakaTool', () => { assert.match(result.text, /stopped at step 1 of 1: outcome_unknown/); }); + test('a sequence reports a retired pre-dispatch step as a duplicate action', async () => { + const backend = fakeBackend(); + backend.observeApp = async () => observation(); + backend.captureObservation = async () => observation(); + let dispatches = 0; + backend.runSemantic = async () => { + dispatches += 1; + return { + outcome: { + ok: false, + error: 'dispatch_refused', + message: 'the executor refused before dispatch', + evidence: { path: 'none' }, + }, + }; + }; + const [tool] = buildComputerUseTools({ backend }); + const observed = (await tool.impl({ action: 'observe', app: 'Fixture' } as never, ctx())) as { + text: string; + }; + const observationId = JSON.parse(observed.text).observation_id as string; + const refused = (await tool.impl( + { + action: 'click_element', + observation_id: observationId, + element_id: '5', + } as never, + ctx(undefined, { toolCallId: 'refused' }), + )) as { error?: string }; + assert.equal(refused.error, 'dispatch_refused'); + + const sequence = (await tool.impl( + { + action: 'element_sequence', + observation_id: observationId, + steps: [{ label: 'Continue' }], + } as never, + ctx(undefined, { toolCallId: 'sequence' }), + )) as { error?: string; text: string }; + assert.equal(sequence.error, 'duplicate_action'); + const modelOutput = tool.toModelOutput?.({ + toolCallId: 'sequence', + input: {}, + output: sequence, + }); + assert.match(JSON.stringify(modelOutput), /stopped at step 0 of 1: duplicate_action/); + assert.match(JSON.stringify(modelOutput), /Address a different element/); + assert.doesNotMatch(JSON.stringify(modelOutput), /retired_action/); + assert.equal(dispatches, 1); + }); + test('a stopped sequence preserves a partially delivered outcome over frame confirmation failure', async () => { const backend = fakeBackend(); backend.observeApp = async () => observation(); @@ -2101,6 +2152,8 @@ describe('buildComputerUseTools — the `maka_computer` MakaTool', () => { const r = await callComputer(fakeBackend({ accessibility: false }), { action: 'wait' }); assert.match(r.text, /permission_missing/); assert.match(r.text, /Accessibility/); + assert.equal((r as { error?: string }).error, 'permission_missing'); + assert.equal((r as { outcome?: string }).outcome, 'error'); }); test('requests Accessibility once on first use while every action still preflights', async () => { @@ -2979,6 +3032,7 @@ describe('buildComputerUseTools — the `maka_computer` MakaTool', () => { const backend = fakeBackend(); const r = await callComputer(backend, { action: 'wait', duration: 0.001 }, ac.signal); assert.match(r.text, /aborted/); + assert.equal((r as { outcome?: string }).outcome, 'aborted'); assert.equal(backend.last, undefined, 'backend.run must not be called after abort'); }); diff --git a/packages/runtime/src/__tests__/computer-use-wait-for.test.ts b/packages/runtime/src/__tests__/computer-use-wait-for.test.ts index 97b7f8c75ee..fc4979d1c47 100644 --- a/packages/runtime/src/__tests__/computer-use-wait-for.test.ts +++ b/packages/runtime/src/__tests__/computer-use-wait-for.test.ts @@ -75,7 +75,12 @@ async function waitFor( b: CuDispatchBackend, args: Record, observeFirst = true, -): Promise<{ text: string; modelText?: string; error?: string }> { +): Promise<{ + text: string; + modelText?: string; + error?: string; + outcome?: string; +}> { const [tool] = buildComputerUseTools({ backend: b }); const context = { abortSignal: new AbortController().signal, @@ -93,6 +98,7 @@ async function waitFor( text: string; modelText?: string; error?: string; + outcome?: string; }; } @@ -114,6 +120,7 @@ test('a wait for text to go returns when it goes', async () => { duration: 5, }); assert.match(result.text, /gone after/); + assert.equal(result.outcome, 'success'); }); test('a timeout hands back the window as it stands', async () => { @@ -122,6 +129,7 @@ test('a timeout hands back the window as it stands', async () => { duration: 0.6, }); assert.equal(result.error, 'timeout'); + assert.equal(result.outcome, 'error'); assert.match(result.text, /was still absent after/); // The whole question a model asks after a timeout is "what is there instead", // and making it spend another call on that is the round trip this removes. diff --git a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts index 9d3cb1760b1..fa6ddffb10c 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -294,6 +294,36 @@ function equivalentLegacyMessages(): StoredMessage[] { } describe('projectRuntimeEventsToStoredMessages', () => { + test('preserves declared aborts while projecting old result events unchanged', () => { + const oldEvents = baseEvents(); + const oldResult = projectRuntimeEventsToStoredMessages(oldEvents, { + invocations: [invocation], + }).messages.find((message) => message.type === 'tool_result'); + assert.equal(oldResult?.type === 'tool_result' && oldResult.outcome, undefined); + + const events = oldEvents.map( + (event): RuntimeEvent => + event.id === 'evt-tool-result' + ? { + ...event, + content: { + kind: 'function_response', + id: 'tool-1', + name: 'Read', + result: { kind: 'text', text: 'file contents' }, + isError: true, + outcome: 'aborted', + }, + } + : event, + ); + const result = projectRuntimeEventsToStoredMessages(events, { + invocations: [invocation], + }).messages.find((message) => message.type === 'tool_result'); + assert.equal(result?.type === 'tool_result' && result.isError, true); + assert.equal(result?.type === 'tool_result' && result.outcome, 'aborted'); + }); + test('streaming projection is equivalent to the batch read model', () => { const events = baseEvents(); const streamed = createRuntimeEventStoredMessageProjector({ invocations: [invocation] }); diff --git a/packages/runtime/src/__tests__/session-trace-projection.test.ts b/packages/runtime/src/__tests__/session-trace-projection.test.ts index 99985ede86e..d0697f0b516 100644 --- a/packages/runtime/src/__tests__/session-trace-projection.test.ts +++ b/packages/runtime/src/__tests__/session-trace-projection.test.ts @@ -229,6 +229,54 @@ describe('session trace projection', () => { assert.equal(failure.message, 'turn ended after tool failure'); }); + test('attributes an aborted tool outcome to the aborted turn, not a tool failure', () => { + const trace = projectSessionTrace({ + sessionId: 'session-1', + runtimeEvents: [ + event({ + id: 'dispatch-1', + ts: 1_000, + actions: { + toolDispatch: { + protocol: 't1_after_preflight_v1', + operationId: 'op-1', + providerToolCallId: 'tool-call-1', + toolName: 'Bash', + canonicalArgsHash: 'hash', + recoveryMode: 'replay_safe', + }, + }, + }), + event({ + id: 'response-1', + ts: 1_200, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'tool-call-1', + name: 'Bash', + result: 'stopped', + isError: true, + outcome: 'aborted', + }, + }), + event({ + id: 'aborted-1', + ts: 1_300, + status: 'aborted', + }), + ], + modelCallAttempts: [], + }); + + const tool = trace.turns[0]?.steps.find((step) => step.kind === 'tool'); + assert.equal(tool?.kind === 'tool' ? tool.status : undefined, 'interrupted'); + assert.equal(trace.turns[0]?.failure?.code, 'turn_aborted'); + assert.equal(trace.turns[0]?.failure?.attributedToStepId, undefined); + assert.equal(isSessionTrace(trace), true, 'the Host protocol accepts interrupted tool steps'); + }); + test('projects a generic-lane function call as an in-flight tool step', () => { const trace = projectSessionTrace({ sessionId: 'session-1', diff --git a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts index aefc6d2551c..01bd89f044f 100644 --- a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts @@ -33,6 +33,94 @@ import type { import { ToolRuntime, type MakaTool } from '../tool-runtime.js'; describe('ToolRuntime durable boundary', () => { + it('commits declared outcomes once and leaves ordinary business status alone', async () => { + const outcomes: ToolOutcomeCommit[] = []; + const telemetry: string[] = []; + const harness = () => + makeHarness( + { + commitToolPrepared: async () => ({ + created: true, + runtimeEventSeq: 1, + }), + commitToolOutcome: async (input) => { + outcomes.push(input); + return { created: true, runtimeEventSeq: 2 }; + }, + }, + undefined, + 'run-1', + { + recordToolInvocation: (record) => telemetry.push(record.status), + }, + ); + for (const outcome of ['error', 'aborted', 'success'] as const) { + const attempt = harness(); + const target = tool(() => ({ + outcome, + content: [{ type: 'text', text: 'report' }], + })); + target.resultOutcome = (output) => (output as { outcome: typeof outcome }).outcome; + await attempt.execute(target); + const response = outcomes.at(-1)?.runtimeEvent.content; + assert.equal(response?.kind === 'function_response' && response.outcome, outcome); + assert.equal( + response?.kind === 'function_response' && response.isError === true, + outcome !== 'success', + ); + assert.deepEqual(response?.kind === 'function_response' ? response.result : undefined, { + kind: 'json', + value: { content: [{ type: 'text', text: 'report' }] }, + }); + const live = attempt.events.at(-1); + assert.equal(live?.type === 'tool_result' && live.outcome, outcome); + assert.equal(live?.type === 'tool_result' && live.isError, outcome !== 'success'); + } + assert.deepEqual(telemetry, ['error', 'aborted', 'success']); + const ordinary = tool(() => ({ outcome: 'error', status: 'failed' })); + const ordinaryAttempt = harness(); + await ordinaryAttempt.execute(ordinary); + const ordinaryEvent = ordinaryAttempt.events.at(-1); + assert.equal(ordinaryEvent?.type === 'tool_result' && ordinaryEvent.isError, false); + }); + + it('commits a thrown stop as aborted without changing ordinary thrown failures', async () => { + const outcomes: ToolOutcomeCommit[] = []; + const telemetry: string[] = []; + const harness = makeHarness( + { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async (input) => { + outcomes.push(input); + return { created: true, runtimeEventSeq: 2 }; + }, + }, + undefined, + 'run-1', + { recordToolInvocation: (record) => telemetry.push(record.status) }, + ); + const controller = new AbortController(); + await harness.execute( + tool(() => { + controller.abort(new Error('user stopped')); + throw controller.signal.reason; + }), + controller.signal, + ); + const aborted = outcomes[0]?.runtimeEvent.content; + assert.equal(aborted?.kind === 'function_response' && aborted.outcome, 'aborted'); + const live = harness.events.at(-1); + assert.equal(live?.type === 'tool_result' && live.outcome, 'aborted'); + await harness.execute( + tool(() => { + throw new Error('ordinary failure'); + }), + ); + const failed = outcomes[1]?.runtimeEvent.content; + assert.equal(failed?.kind === 'function_response' && failed.outcome, 'error'); + assert.deepEqual(telemetry, ['aborted', 'error']); + }); + it('does not invoke the tool or publish a result when T1 fails', async () => { let implementationCalls = 0; const harness = makeHarness({ diff --git a/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts index 9bbb6eb4d44..0633090b8ca 100644 --- a/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts @@ -394,6 +394,42 @@ describe('ToolRuntime settlement', () => { } }); + it('records a thrown Bash cancellation as an aborted tool outcome', async () => { + const runtime = makeRuntime(); + const events: SessionEvent[] = []; + const bash = buildManagedBashTool({ + runForegroundBash: async () => { + throw Object.assign(new Error('cancelled'), { + code: 130, + stdout: '', + stderr: 'cancelled', + }); + }, + runBackgroundBash: async () => { + throw new Error('not used'); + }, + }); + + await runtime.settleToolCall({ + tool: bash, + turnId: 'turn-1', + stepId: 'step-1', + toolCallId: 'call-cancelled', + input: { command: 'printf cancelled', boundary_intent: 'current' }, + abortSignal: new AbortController().signal, + eventSink: { + push: (event) => events.push(event), + pushAndWaitUntilConsumed: async (event) => { + events.push(event); + }, + }, + }); + + const result = events.find((event) => event.type === 'tool_result'); + assert.equal(result?.type, 'tool_result'); + assert.equal(result?.outcome, 'aborted'); + }); + it('preserves live provider error mapping', async () => { const runtime = makeRuntime(); const events: SessionEvent[] = []; diff --git a/packages/runtime/src/computer-use-tools.ts b/packages/runtime/src/computer-use-tools.ts index b98af051703..15e4ea31574 100644 --- a/packages/runtime/src/computer-use-tools.ts +++ b/packages/runtime/src/computer-use-tools.ts @@ -93,7 +93,10 @@ import type { CuSemanticAction, } from './computer-use-types.js'; -export { adaptToCuAction, snapshotComputerParams } from './computer-use-codec.js'; +export { + adaptToCuAction, + snapshotComputerParams, +} from './computer-use-codec.js'; export type { CuAppSummary, CuDispatchBackend, @@ -322,6 +325,21 @@ interface ComputerToolResult { includeScreenshotInModelOutput?: boolean; } +type SettledComputerToolResult = ComputerToolResult & + ( + | { outcome: 'success'; error?: never } + | { outcome: 'error'; error: ComputerUseErrorCode } + | { outcome: 'aborted'; error: 'user_stopped' } + ); + +function settleComputerResult(result: ComputerToolResult): SettledComputerToolResult { + if (result.error === 'user_stopped') + return { ...result, error: 'user_stopped', outcome: 'aborted' }; + if (result.error) return { ...result, error: result.error, outcome: 'error' }; + const { error: _error, ...content } = result; + return { ...content, outcome: 'success' }; +} + export const COMPUTER_USE_MODEL_SCREENSHOT_POLICY = { list_apps: 'never', launch_app: 'never', @@ -679,7 +697,12 @@ export function buildComputerUseTools(deps: { windowId?: number; elements?: Map; /** From the last observation: the windows stacked above the target. */ - obscuringRects?: Array<{ x: number; y: number; width: number; height: number }>; + obscuringRects?: Array<{ + x: number; + y: number; + width: number; + height: number; + }>; } const observations = new Map(); interface SessionStateRecord { @@ -943,15 +966,18 @@ export function buildComputerUseTools(deps: { | 'target_changed' | 'capture_failed'; + function publicBindingFailureCode(reason: string): ComputerUseErrorCode | undefined { + if (isComputerUseErrorCode(reason)) return reason; + if (reason === 'retired_action') return 'duplicate_action'; + if (reason === 'invalid_binding') return 'stale_frame'; + return undefined; + } + function bindingFailure(reason: BindingFailureReason, action?: string): ComputerToolResult { // `retired_action` is an internal distinction, not a twenty-ninth word for // the model: it is the same fact as `duplicate_action` with a different // recovery, and the recovery is the sentence, not the code. - const error: ComputerUseErrorCode = isComputerUseErrorCode(reason) - ? reason - : reason === 'retired_action' - ? 'duplicate_action' - : 'stale_frame'; + const error = publicBindingFailureCode(reason) ?? 'stale_frame'; const tool = action ? `maka_computer.${action}` : 'maka_computer'; return { text: `${tool} failed: ${error} — ${BINDING_FAILURE_RECOVERY[reason]}`, @@ -1471,7 +1497,11 @@ export function buildComputerUseTools(deps: { dispatch(context: CuRunContext): Promise; }): Promise< | { blocked: ComputerToolResult; result?: never; finish?: never } - | { blocked?: never; result: CuRunResult; finish(result?: CuRunResult): void } + | { + blocked?: never; + result: CuRunResult; + finish(result?: CuRunResult): void; + } > { const { state, lease, record, binding, action, signal } = input; let result: CuRunResult | undefined; @@ -1515,12 +1545,14 @@ export function buildComputerUseTools(deps: { } if (consumeFailure && !hasUncertainDeliveredOutcome(result)) { presentation.finish(); - return { blocked: refusalAfterDispatch(consumeFailure, result, action.type) }; + return { + blocked: refusalAfterDispatch(consumeFailure, result, action.type), + }; } return { result, finish: presentation.finish }; } - const tool: MakaTool = { + const rawTool: MakaTool = { name: 'maka_computer', displayName: 'Maka Computer', // The kind every other builtin declares, and the reason `'computer'` is on @@ -1631,7 +1663,9 @@ export function buildComputerUseTools(deps: { ...('element_id' in input && input.element_id !== undefined && record.elements?.get(input.element_id)?.identity - ? { element_identity: record.elements.get(input.element_id)!.identity } + ? { + element_identity: record.elements.get(input.element_id)!.identity, + } : {}), }; }, @@ -1639,7 +1673,8 @@ export function buildComputerUseTools(deps: { args, { abortSignal, sessionId, turnId, toolCallId, emitProgress }, ): Promise => { - if (abortSignal.aborted) return { text: 'computer aborted before start' }; + if (abortSignal.aborted) + return { text: 'computer aborted before start', error: 'user_stopped' }; const input = snapshotComputerParams(computerParams.parse(args)); const includeScreenshotInModelOutput = shouldSendScreenshotToModel(input); // Before anything is claimed against a frame or dispatched: an argument @@ -1714,6 +1749,7 @@ export function buildComputerUseTools(deps: { } } return { + error: 'permission_missing', text: 'maka_computer failed: permission_missing — Accessibility not granted (System Settings → Privacy & Security → Accessibility)', }; } @@ -1721,6 +1757,7 @@ export function buildComputerUseTools(deps: { if (input.action === 'element_sequence') { if (!deps.backend.runSemantic || !deps.backend.captureObservation) { return { + error: 'unsupported_action', text: 'maka_computer.element_sequence failed: unsupported_action — ' + `${MISSING_CAPABILITY} Send the steps one at a time with click_element or ` + @@ -1729,6 +1766,7 @@ export function buildComputerUseTools(deps: { } if (!tcc.screenRecording) { return { + error: 'permission_missing', text: 'maka_computer.element_sequence failed: permission_missing — Screen Recording not granted (System Settings → Privacy & Security → Screen Recording)', }; } @@ -1750,7 +1788,12 @@ export function buildComputerUseTools(deps: { elements: [...record.elements.values()], } : undefined; - const done: Array<{ step: number; label: string; ok: boolean; detail?: string }> = []; + const done: Array<{ + step: number; + label: string; + ok: boolean; + detail?: string; + }> = []; let stopped: string | undefined; emitProgress?.(0, input.steps.length); for (const [index, step] of input.steps.entries()) { @@ -1775,7 +1818,11 @@ export function buildComputerUseTools(deps: { // measured on a real run, `stopped at step 1 of 9: // capture_failed` with step 1 reported `ok`, so a nine-key // calculation could never get past its first key. - { app: record.appId, windowId: record.windowId, includeScreenshot: false }, + { + app: record.appId, + windowId: record.windowId, + includeScreenshot: false, + }, abortSignal, runCtx, ); @@ -1849,7 +1896,10 @@ export function buildComputerUseTools(deps: { stopped = 'stale_frame'; break; } - const backendAction = { ...semantic, observationId: record.backendObservationId }; + const backendAction = { + ...semantic, + observationId: record.backendObservationId, + }; const execution = await executeBoundAction({ state, lease: actionLeaseResult.lease, @@ -1919,8 +1969,15 @@ export function buildComputerUseTools(deps: { } catch { final = undefined; } - const headline = stopped - ? `maka_computer.element_sequence stopped at step ${done.length} of ${input.steps.length}: ${stopped}` + const stoppedError = stopped + ? (publicBindingFailureCode(stopped) ?? 'outcome_unknown') + : undefined; + const stoppedRecovery = + stopped && stopped in BINDING_FAILURE_RECOVERY + ? BINDING_FAILURE_RECOVERY[stopped as BindingFailureReason] + : undefined; + const headline = stoppedError + ? `maka_computer.element_sequence stopped at step ${done.length} of ${input.steps.length}: ${stoppedError}${stoppedRecovery ? ` — ${stoppedRecovery}` : ''}` : closingBlock ? `maka_computer.element_sequence failed after ${done.length} of ${input.steps.length} steps: ${closingBlock} — ${SESSION_BLOCK_RECOVERY[closingBlock]}` : `maka_computer.element_sequence ok (${done.length} of ${input.steps.length} steps)`; @@ -1937,8 +1994,8 @@ export function buildComputerUseTools(deps: { return { text: `${headline}${persistedTail}`, modelText: `${headline}\n${stepLines}${modelTail}`, - ...(stopped && isComputerUseErrorCode(stopped) - ? { error: stopped } + ...(stoppedError + ? { error: stoppedError } : closingBlock ? { error: closingBlock } : {}), @@ -1955,6 +2012,7 @@ export function buildComputerUseTools(deps: { if (input.action === 'launch_app') { if (!deps.backend.launchApp) { return { + error: 'unsupported_action', text: 'maka_computer.launch_app failed: unsupported_action — ' + `${MISSING_CAPABILITY} Ask the user to open the application, then call ` + @@ -1985,6 +2043,7 @@ export function buildComputerUseTools(deps: { if (input.action === 'list_apps') { if (!deps.backend.listApps) { return { + error: 'unsupported_action', text: 'maka_computer.list_apps failed: unsupported_action — ' + `${MISSING_CAPABILITY} Name the application directly in action:"observe" ` + @@ -2086,6 +2145,7 @@ export function buildComputerUseTools(deps: { const record = observations.get(sessionId); if (!deps.backend.observeApp || !record?.appId) { return { + error: 'no_active_frame', text: 'maka_computer.wait failed: no_active_frame — a condition is checked against the window you last observed, and there is none yet. Observe first, or wait with only a duration.', }; } @@ -2114,6 +2174,7 @@ export function buildComputerUseTools(deps: { }; } return { + error: 'target_missing', text: 'maka_computer.wait failed: target_missing — the window being waited on is no longer there', }; } @@ -2166,6 +2227,7 @@ export function buildComputerUseTools(deps: { if (input.action === 'observe') { if (!deps.backend.observeApp) { return { + error: 'unsupported_action', text: 'maka_computer.observe failed: unsupported_action — ' + `${MISSING_CAPABILITY} Nothing on this computer can be read or driven; ` + @@ -2194,6 +2256,7 @@ export function buildComputerUseTools(deps: { const includeScreenshot = input.include_screenshot ?? false; if (includeScreenshot && !tcc.screenRecording) { return { + error: 'permission_missing', text: 'maka_computer.observe failed: permission_missing — Screen Recording not ' + 'granted (System Settings → Privacy & Security → Screen Recording). ' + @@ -2228,6 +2291,7 @@ export function buildComputerUseTools(deps: { const resolvedApp = await resolveAppName(input.app, abortSignal); if (resolvedApp && 'ambiguous' in resolvedApp) { return { + error: 'ambiguous_target', text: `maka_computer.observe failed: ambiguous_target — "${input.app}" matches ` + `${resolvedApp.ambiguous.join(', ')}. Name one of them.`, @@ -2337,7 +2401,10 @@ export function buildComputerUseTools(deps: { ? { text: persistedObservationText(observation), modelText: observationText({ ...observation, screenshot }), - screenshot: { base64: screenshot.base64, mimeType: screenshot.mimeType }, + screenshot: { + base64: screenshot.base64, + mimeType: screenshot.mimeType, + }, includeScreenshotInModelOutput, } : { @@ -2348,6 +2415,7 @@ export function buildComputerUseTools(deps: { if (input.action === 'screenshot') { if (!deps.backend.observeApp) { return { + error: 'unsupported_action', text: 'maka_computer.screenshot failed: unsupported_action — ' + `${MISSING_CAPABILITY} Use action:"observe", which returns the same window ` + @@ -2356,6 +2424,7 @@ export function buildComputerUseTools(deps: { } if (!tcc.screenRecording) { return { + error: 'permission_missing', text: 'maka_computer.screenshot failed: permission_missing — ' + 'Screen Recording not granted ' + @@ -2382,7 +2451,10 @@ export function buildComputerUseTools(deps: { ); } if (!screenshotObservation.screenshot) { - return { text: 'maka_computer.screenshot failed: capture_failed' }; + return { + text: 'maka_computer.screenshot failed: capture_failed', + error: 'capture_failed', + }; } return { text: JSON.stringify({ @@ -2418,6 +2490,7 @@ export function buildComputerUseTools(deps: { ) { if (!deps.backend.runSemantic) { return { + error: 'unsupported_action', text: `maka_computer.${input.action} failed: unsupported_action — ` + `${MISSING_CAPABILITY} No element offers it either; report the limit instead ` + @@ -2426,6 +2499,7 @@ export function buildComputerUseTools(deps: { } if (!tcc.screenRecording) { return { + error: 'permission_missing', text: `maka_computer.${input.action} failed: permission_missing — Screen Recording not granted (System Settings → Privacy & Security → Screen Recording)`, }; } @@ -2473,7 +2547,9 @@ export function buildComputerUseTools(deps: { direction: input.scroll_direction ?? 'down', ...(input.scroll_amount === undefined ? {} - : { pages: input.scroll_amount / SCROLL_UNITS_PER_PAGE }), + : { + pages: input.scroll_amount / SCROLL_UNITS_PER_PAGE, + }), elementIdentity: record.elements?.get(input.element_id)?.identity, } : input.action === 'window_action' @@ -2483,10 +2559,20 @@ export function buildComputerUseTools(deps: { elementId: input.element_id, action: input.window_action, ...(input.position - ? { position: { x: input.position[0], y: input.position[1] } } + ? { + position: { + x: input.position[0], + y: input.position[1], + }, + } : {}), ...(input.size - ? { size: { width: input.size[0], height: input.size[1] } } + ? { + size: { + width: input.size[0], + height: input.size[1], + }, + } : {}), elementIdentity: record.elements?.get(input.element_id)?.identity, } @@ -2660,6 +2746,7 @@ export function buildComputerUseTools(deps: { if (requiresActionLease) { if (!tcc.screenRecording) { return { + error: 'permission_missing', text: `maka_computer.${action.type} failed: permission_missing — Screen Recording not granted (System Settings → Privacy & Security → Screen Recording)`, }; } @@ -2672,6 +2759,7 @@ export function buildComputerUseTools(deps: { const capturing = action.type === 'screenshot'; if (capturing && !tcc.screenRecording) { return { + error: 'permission_missing', text: 'maka_computer failed: permission_missing — Screen Recording not granted (System Settings → Privacy & Security → Screen Recording)', }; } @@ -2784,7 +2872,10 @@ export function buildComputerUseTools(deps: { modelText, ...(!result.outcome.ok ? { error: result.outcome.error } : {}), ...(failureClass ? { failureClass } : {}), - screenshot: { base64: screenshot.base64, mimeType: screenshot.mimeType }, + screenshot: { + base64: screenshot.base64, + mimeType: screenshot.mimeType, + }, includeScreenshotInModelOutput, } : { @@ -2804,7 +2895,9 @@ export function buildComputerUseTools(deps: { // PiP frame stays local. Explicit visual requests receive the native image // block. toModelOutput: ({ output }) => { - const o = (output ?? {}) as Partial & { error?: unknown }; + const o = (output ?? {}) as Partial & { + error?: unknown; + }; const text = typeof o.modelText === 'string' ? redactSecrets(o.modelText) @@ -2830,6 +2923,11 @@ export function buildComputerUseTools(deps: { }; }, }; + const tool: MakaTool = { + ...rawTool, + resultOutcome: (output) => (output as SettledComputerToolResult).outcome, + impl: async (args, context) => settleComputerResult(await rawTool.impl(args, context)), + }; const debug = deps.debug; if (debug) { const dispatch = tool.impl; diff --git a/packages/runtime/src/mcp-tools.ts b/packages/runtime/src/mcp-tools.ts index 3b6021c9d6d..11ae03ca2a6 100644 --- a/packages/runtime/src/mcp-tools.ts +++ b/packages/runtime/src/mcp-tools.ts @@ -91,6 +91,10 @@ export interface McpToolInvocationContext { } export interface BuildMcpToolsOptions { + /** Only a trusted adapter with a validated result envelope installs this reader. */ + resultOutcome?: ( + output: McpCallResult, + ) => import('@maka/core/tool-result-status').ToolCallOutcome; callTimeoutMs?: number; categoryHint?: ToolCategory; hostAdmission?: MakaTool['hostAdmission']; @@ -144,6 +148,7 @@ export function buildMcpToolsWithIdentities( // The MCP server remains the sole authority for the complete JSON // Schema. Runtime only carries the declaration to the AI SDK. parameters: jsonSchema(inputSchema), + ...(options.resultOutcome ? { resultOutcome: options.resultOutcome } : {}), ...(provider.prepareTool ? { prepareExecution: async (args: unknown, context) => { diff --git a/packages/runtime/src/runtime-event-backfill.ts b/packages/runtime/src/runtime-event-backfill.ts index be32b644d6b..059ea60b607 100644 --- a/packages/runtime/src/runtime-event-backfill.ts +++ b/packages/runtime/src/runtime-event-backfill.ts @@ -286,6 +286,7 @@ export function backfillRuntimeEventsFromStoredMessages( name: call?.toolName ?? '', result: message.content, isError: message.isError, + ...(message.outcome ? { outcome: message.outcome } : {}), ...(message.providerExecuted !== undefined ? { providerExecuted: message.providerExecuted } : {}), diff --git a/packages/runtime/src/runtime-event-read-model.ts b/packages/runtime/src/runtime-event-read-model.ts index a15cf9ffac0..502d45c7dde 100644 --- a/packages/runtime/src/runtime-event-read-model.ts +++ b/packages/runtime/src/runtime-event-read-model.ts @@ -1242,6 +1242,7 @@ function projectFunctionResponse( ts: event.ts, toolUseId, isError: event.content.isError === true, + ...(event.content.outcome ? { outcome: event.content.outcome } : {}), content: resultContent, ...(event.content.providerExecuted !== undefined ? { providerExecuted: event.content.providerExecuted } diff --git a/packages/runtime/src/session-event-runtime-mapper.ts b/packages/runtime/src/session-event-runtime-mapper.ts index b7cdb2636f6..a49d0bc94a1 100644 --- a/packages/runtime/src/session-event-runtime-mapper.ts +++ b/packages/runtime/src/session-event-runtime-mapper.ts @@ -363,6 +363,7 @@ function mapBackendSessionEvent( name, result: event.content, ...(event.isError ? { isError: true as const } : {}), + ...(event.outcome ? { outcome: event.outcome } : {}), ...(event.providerExecuted !== undefined ? { providerExecuted: event.providerExecuted } : {}), diff --git a/packages/runtime/src/session-trace-projection.ts b/packages/runtime/src/session-trace-projection.ts index 204f1c53a12..84cd415057b 100644 --- a/packages/runtime/src/session-trace-projection.ts +++ b/packages/runtime/src/session-trace-projection.ts @@ -373,7 +373,12 @@ function projectEventSteps(events: readonly RuntimeEvent[]): TraceStep[] { if (settled) { settled.endedAt = event.ts; settled.durationMs = Math.max(0, event.ts - settled.startedAt); - settled.status = response.isError === true ? 'failed' : 'completed'; + settled.status = + response.outcome === 'aborted' + ? 'interrupted' + : response.isError === true + ? 'failed' + : 'completed'; } continue; } diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index 759b3c3f5e9..eb9983a21f4 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -18,6 +18,7 @@ */ import { decodeCanonicalToolResultContent } from '@maka/core/tool-result-record-schema'; +import { requireToolCallOutcome, type ToolCallOutcome } from '@maka/core/tool-result-status'; import { projectAgentSwarmResult } from '@maka/core/agent-swarm'; import { projectToolActivityArgs } from '@maka/core/tool-activity-args'; import { resolveCollaborationPermissionMode } from '@maka/core/collaboration'; @@ -223,6 +224,8 @@ export interface MakaTool

{ * settlement instead of detaching, so late side effects cannot outlive `exec`. */ impl: (args: P, ctx: MakaToolContext) => Promise | R; + /** Explicit call status for tools with a declared result envelope. */ + resultOutcome?(output: R): ToolCallOutcome; /** Best-effort compensation after T2 rejects a result that already produced side effects. */ compensateDurableOutcomeCommitFailure?: (input: { readonly result: unknown; @@ -453,6 +456,7 @@ interface DurableToolAttempt { isError: boolean, modelProjection: DurableToolResultProjection, durationMs?: number, + outcome?: ToolCallOutcome, ): Promise<{ id: string; operationId: string; ts: number }>; } @@ -994,6 +998,7 @@ export class ToolRuntime { uncertainOutcome?: ToolUncertainOutcomeSignal, activityIdentity: ToolActivityIdentity = {}, attempt?: DurableToolAttempt, + outcome?: ToolCallOutcome, ): Promise { const content: ToolResultContent = { kind: 'text', @@ -1020,6 +1025,7 @@ export class ToolRuntime { name: toolName, result: content, isError: true, + ...(outcome ? { outcome } : {}), }, this.input.sessionId, ) ?? DURABLE_TOOL_RESULT_PROJECTION_FAILURE; @@ -1028,6 +1034,7 @@ export class ToolRuntime { turnId, toolUseId, isError: true, + ...(outcome ? { outcome } : {}), content, modelProjection, activityIdentity, @@ -1040,6 +1047,7 @@ export class ToolRuntime { turnId: string; toolUseId: string; isError: boolean; + outcome?: ToolCallOutcome; content: ToolResultContent; modelProjection: DurableToolResultProjection; durationMs?: number; @@ -1051,6 +1059,7 @@ export class ToolRuntime { input.isError, input.modelProjection, input.durationMs, + input.outcome, ); input.queue.push({ type: 'tool_result', @@ -1060,6 +1069,7 @@ export class ToolRuntime { toolUseId: input.toolUseId, ...(durableOutcome ? { operationId: durableOutcome.operationId } : {}), isError: input.isError, + ...(input.outcome ? { outcome: input.outcome } : {}), content: input.content, modelProjection: input.modelProjection, ...(input.durationMs !== undefined ? { durationMs: input.durationMs } : {}), @@ -1625,30 +1635,39 @@ export class ToolRuntime { ) { throw new ToolResultLimitError(); } - const content = coerceResultContent(result); + const declaredOutcome = tool.resultOutcome + ? requireToolCallOutcome(tool.resultOutcome(result)) + : undefined; + const content = coerceResultContent( + declaredOutcome === undefined + ? result + : stripDeclaredToolOutcome(result, declaredOutcome), + ); const projected = this.projectToolResult(tool, turnId, toolUseId, executionArgs, result); const modelProjection = isPromiseLike(projected) ? await projected : projected; + const outcome = declaredOutcome ?? deriveToolResultStatus(content, result); return { result, content, - isError: deriveToolResultStatus(content, result) !== 'success', + outcome, durationMs: this.input.now() - startedAt, modelProjection, }; }; - const { result, content, isError, durationMs, modelProjection } = + const { result, content, outcome, durationMs, modelProjection } = await prepareOperationValue(); + const isError = outcome !== 'success'; output.flush(); - // Keep the full provider-facing terminal classification. `isError` is - // sufficient for the durable response envelope, but it intentionally - // collapses `aborted` into an error bit and therefore cannot drive live - // tool status, telemetry, or subagent lifecycle projection. - const toolResultStatus = deriveToolResultStatus(content, result); + // Keep the full provider-facing terminal classification. `isError` + // remains a compatibility bit, while explicit outcomes preserve aborts + // for durable replay, live status, telemetry, and subagent projection. + const toolResultStatus = outcome; await this.commitAndPublishToolResult({ queue, turnId, toolUseId, isError, + outcome, content, modelProjection, durationMs, @@ -1758,6 +1777,11 @@ export class ToolRuntime { executionArgs, err, ); + const failureOutcome = + !uncertainOutcome && + (ctx.abortSignal.aborted || terminalFailure?.content.status === 'cancelled') + ? 'aborted' + : 'error'; if (terminalFailure) { if (terminalFailure.sandboxDenied) { const denialKey = sandboxDenialKey(tool.name, this.input.header.cwd, executionArgs); @@ -1788,6 +1812,7 @@ export class ToolRuntime { turnId, toolUseId, isError: true, + outcome: failureOutcome, content: terminalFailure.content, modelProjection, durationMs, @@ -1802,7 +1827,7 @@ export class ToolRuntime { providerId: this.input.connection.providerType, modelId: this.input.modelId, durationMs, - status: 'error', + status: failureOutcome, errorClass, argsSummary: tool.categoryHint === 'computer_use' @@ -1817,7 +1842,7 @@ export class ToolRuntime { toolUseId, toolName: tool.name, durationMs, - status: 'error', + status: failureOutcome, errorClass, ...(sandboxError ? { sandbox: sandboxError } : {}), }); @@ -1842,6 +1867,7 @@ export class ToolRuntime { uncertainOutcome, activityIdentity, durableAttempt, + failureOutcome, ); this.input.recordToolInvocation?.({ sessionId: this.input.sessionId, @@ -1851,7 +1877,7 @@ export class ToolRuntime { providerId: this.input.connection.providerType, modelId: this.input.modelId, durationMs: Math.max(0, this.input.now() - startedAt), - status: 'error', + status: failureOutcome, errorClass, argsSummary: tool.categoryHint === 'computer_use' @@ -1865,7 +1891,7 @@ export class ToolRuntime { toolUseId, toolName: tool.name, durationMs: Math.max(0, this.input.now() - startedAt), - status: 'error', + status: failureOutcome, errorClass, ...(sandboxError ? { sandbox: sandboxError } : {}), }); @@ -2008,6 +2034,7 @@ export class ToolRuntime { isError: boolean, modelProjection: DurableToolResultProjection, durationMs: number | undefined, + outcome: ToolCallOutcome | undefined, ts: number, ): RuntimeEvent => ({ id: `${operationId}_response`, @@ -2027,6 +2054,7 @@ export class ToolRuntime { name: input.tool.name, result, ...(isError ? { isError: true } : {}), + ...(outcome ? { outcome } : {}), modelProjection, }, refs: { @@ -2044,13 +2072,14 @@ export class ToolRuntime { let committedOutcome: { id: string; operationId: string; ts: number } | undefined; return { operationId, - commitOutcome: async (result, isError, modelProjection, durationMs) => { + commitOutcome: async (result, isError, modelProjection, durationMs, outcome) => { if (committedOutcome) return committedOutcome; const responseEvent = buildResponseEvent( result, isError, modelProjection, durationMs, + outcome, this.input.now(), ); try { @@ -3212,7 +3241,7 @@ function uncertainOutcomeSignalFromError(error: unknown): ToolUncertainOutcomeSi }; } -function coerceResultContent(raw: unknown): ToolResultContent { +export function coerceResultContent(raw: unknown): ToolResultContent { if (typeof raw === 'string') return { kind: 'text', text: raw }; if (raw && typeof raw === 'object') { const obj = raw as { kind?: string; text?: string }; @@ -3229,6 +3258,14 @@ function coerceResultContent(raw: unknown): ToolResultContent { return { kind: 'text', text: String(raw ?? '') }; } +function stripDeclaredToolOutcome(raw: unknown, outcome: ToolCallOutcome): unknown { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return raw; + const record = raw as Record; + if (record.outcome !== outcome) return raw; + const { outcome: _outcome, ...content } = record; + return content; +} + function coerceTerminalFailure( tool: MakaTool, cwd: string, @@ -3359,7 +3396,7 @@ function isBoundaryAuthorityAttempt(toolName: string, args: unknown): boolean { ); } -function deriveToolResultStatus( +export function deriveToolResultStatus( content: ToolResultContent, raw?: unknown, ): ToolInvocationRecord['status'] { @@ -3395,10 +3432,25 @@ function deriveToolResultStatus( content.operation.failed ) return 'error'; - // All other structured results are successful tool executions. That includes - // ShellRun observations: their embedded process status stays model-visible, - // but reading or returning the observation itself succeeded. - return 'success'; + // Observing a failed process is still a successful call. Business JSON has + // the same legacy behavior unless its tool declares an outcome reader. + switch (content.kind) { + case 'text': + case 'json': + case 'file_diff': + case 'file_write': + case 'image': + case 'summary': + case 'archived_tool_result': + case 'web_search': + case 'shell_run': + case 'rive_workflow': + return 'success'; + default: { + const exhaustive: never = content; + return exhaustive; + } + } } function summarizeToolResultForTelemetry( diff --git a/packages/storage/src/__tests__/sqlite-runtime-store.test.ts b/packages/storage/src/__tests__/sqlite-runtime-store.test.ts index d490e77b3c7..34b10ad66a0 100644 --- a/packages/storage/src/__tests__/sqlite-runtime-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-runtime-store.test.ts @@ -2275,6 +2275,36 @@ describe('SqliteRuntimeStore', () => { }); }); + it('retains an aborted tool outcome after reopening the T2 ledger', async () => { + await withStore(async (store, dbPath) => { + await commitPrepared(store); + const response = functionResponseEvent({ + content: { + kind: 'function_response', + id: 'provider-call-1', + name: 'Read', + result: { kind: 'text', text: 'stopped' }, + isError: true, + outcome: 'aborted', + }, + }); + await store.commitToolOutcome({ + operationId: 'operation-1', + journalEventId: 'operation-1_outcome', + runtimeEvent: response, + committedAt: 20, + }); + store.close(); + const reopened = createSqliteRuntimeStore(dbPath); + try { + const events = await reopened.readRuntimeEvents('session-1', 'run-1'); + assert.deepEqual(events.at(-1), response); + } finally { + reopened.close(); + } + }); + }); + it('keeps projected T2 prepared when its atomic model projection is missing', async () => { await withStore(async (store) => { await commitPrepared(store, { resultProjectionVersion: 1 }); diff --git a/packages/ui/src/__tests__/live-turn-projection.test.ts b/packages/ui/src/__tests__/live-turn-projection.test.ts index 6e265d8c531..30dcb5f0ed4 100644 --- a/packages/ui/src/__tests__/live-turn-projection.test.ts +++ b/packages/ui/src/__tests__/live-turn-projection.test.ts @@ -401,6 +401,21 @@ describe('applyLiveTurnEvent', () => { assert.equal(projection.steps[0]?.tools[0]?.status, 'interrupted'); }); + it('uses an explicit abort when the live result content is omitted', () => { + const projection = applyLiveTurnEvent(undefined, { + type: 'tool_result', + id: 'event-aborted', + turnId: 'turn-1', + toolUseId: 'tool-1', + isError: true, + outcome: 'aborted', + contentOmitted: true, + content: { kind: 'text', text: '' }, + ts: 101, + }); + assert.equal(projection.steps[0]?.tools[0]?.status, 'interrupted'); + }); + it('moves an output-first tool into its real step without duplicating or regressing it', () => { const output = applyLiveTurnEvent(undefined, { diff --git a/packages/ui/src/__tests__/materialize.test.ts b/packages/ui/src/__tests__/materialize.test.ts index 115cce2a099..ce12d446ab9 100644 --- a/packages/ui/src/__tests__/materialize.test.ts +++ b/packages/ui/src/__tests__/materialize.test.ts @@ -373,6 +373,95 @@ test('retains persisted nested tool activity identity', () => { }); }); +test('scopes persisted tool results to their turn when opaque call ids repeat', () => { + const turns = materializeTurns([ + userMsg('turn-1', 1, 'first'), + { + type: 'tool_call', + id: 'call-1', + turnId: 'turn-1', + ts: 2, + toolName: 'Read', + args: { path: 'first.txt' }, + }, + { + type: 'tool_result', + id: 'result-1', + turnId: 'turn-1', + ts: 3, + toolUseId: 'call-1', + isError: false, + outcome: 'success', + content: { kind: 'text', text: 'first result' }, + }, + userMsg('turn-2', 4, 'second'), + { + type: 'tool_call', + id: 'call-1', + turnId: 'turn-2', + ts: 5, + toolName: 'Read', + args: { path: 'second.txt' }, + }, + { + type: 'tool_result', + id: 'result-2', + turnId: 'turn-2', + ts: 6, + toolUseId: 'call-1', + isError: true, + outcome: 'aborted', + content: { kind: 'text', text: 'second result' }, + }, + ] satisfies StoredMessage[], 'en'); + + assert.deepEqual( + turns.map((turn) => ({ + turnId: turn.turnId, + status: turn.tools[0]?.status, + result: turn.tools[0]?.result, + })), + [ + { + turnId: 'turn-1', + status: 'completed', + result: { kind: 'text', text: 'first result' }, + }, + { + turnId: 'turn-2', + status: 'interrupted', + result: { kind: 'text', text: 'second result' }, + }, + ], + ); +}); + +test('does not attach a retained old result to a reused call id at a history boundary', () => { + const [tool] = materializeTools([ + { + type: 'tool_result', + id: 'old-result', + turnId: 'turn-old', + ts: 1, + toolUseId: 'provider-call-1', + isError: true, + outcome: 'error', + content: { kind: 'text', text: 'old failure' }, + }, + { + type: 'tool_call', + id: 'provider-call-1', + turnId: 'turn-new', + ts: 2, + toolName: 'Read', + args: { path: 'new.txt' }, + }, + ] satisfies StoredMessage[]); + + assert.equal(tool?.result, undefined); + assert.notEqual(tool?.status, 'errored'); +}); + function shellRunResult(revision: number) { return { kind: "shell_run" as const, diff --git a/packages/ui/src/live-turn-projection.ts b/packages/ui/src/live-turn-projection.ts index 8e5783d4fdc..a47a8068104 100644 --- a/packages/ui/src/live-turn-projection.ts +++ b/packages/ui/src/live-turn-projection.ts @@ -471,7 +471,7 @@ function projectLiveTurnEvent( const tool: ToolActivityItem = { ...base, ...projectToolActivityIdentity(event), - status: toolResultActivityStatus(event.isError, event.content), + status: toolResultActivityStatus(event.isError, event.content, event.outcome), result: event.contentOmitted ? base.result : event.content, ...(event.durationMs !== undefined ? { durationMs: event.durationMs } : {}), }; diff --git a/packages/ui/src/materialize.ts b/packages/ui/src/materialize.ts index 57ce7514958..0d59e37245f 100644 --- a/packages/ui/src/materialize.ts +++ b/packages/ui/src/materialize.ts @@ -186,18 +186,32 @@ function systemNoteLabel(kind: string, data: unknown, locale: UiLocale): string export function materializeTools( messages: readonly StoredMessage[], ): ToolActivityItem[] { - const results = new Map( - messages - .filter((message) => message.type === "tool_result") - .map((message) => [message.toolUseId, message]), - ); + const latestCallsByUseId = new Map< + string, + Extract + >(); + const resultsByCall = new Map< + Extract, + Extract + >(); + // Append order bounds an opaque ID's ownership: a result belongs to the latest + // preceding call, and a later call with that ID starts a new interval. + for (const message of messages) { + if (message.type === "tool_call") { + latestCallsByUseId.set(message.id, message); + continue; + } + if (message.type !== "tool_result") continue; + const call = latestCallsByUseId.get(message.toolUseId); + if (call) resultsByCall.set(call, message); + } const turnStatusById = new Map( deriveTurnRecords(messages).map((turn) => [turn.turnId, turn.status]), ); return messages .filter((message) => message.type === "tool_call") .map((call) => { - const result = results.get(call.id); + const result = resultsByCall.get(call); return { toolUseId: call.id, toolName: call.toolName, @@ -222,7 +236,7 @@ export function materializeTools( function materializeToolResultStatus( result: Extract, ): ToolActivityItem["status"] { - return toolResultActivityStatus(result.isError, result.content); + return toolResultActivityStatus(result.isError, result.content, result.outcome); } /** @@ -790,16 +804,24 @@ export function materializeTurns( } } - // Second pass: build the canonical tool map. Live tools are applied - // separately by overlayLiveTurn so streaming deltas never force settled - // history to rematerialize. - const toolItemByUseId = new Map( - foldShellRunToolActivities(materializeTools(messages)).map((tool) => [ - tool.toolUseId, - tool, - ]), + const toolCalls = messages.filter( + (message): message is Extract => + message.type === "tool_call", + ); + const foldedTools = foldScopedShellRunToolActivities( + materializeTools(messages).map((item, index) => ({ + turnId: toolCalls[index]!.turnId, + item, + })), ); - // Third pass: rebuild each turn's render timeline from its storage-ordered + const toolItemsByTurnId = new Map>(); + for (const { turnId, item } of foldedTools) { + const turnTools = toolItemsByTurnId.get(turnId); + if (turnTools) turnTools.set(item.toolUseId, item); + else toolItemsByTurnId.set(turnId, new Map([[item.toolUseId, item]])); + } + + // Second pass: rebuild each turn's render timeline from its storage-ordered // messages, interleaving a step's thinking/text with its paired tools. The // timeline is the turn's only tool authority; `tools` is flattened out of it // so the two can never disagree about which tools a turn holds (a tool_call @@ -807,9 +829,10 @@ export function materializeTurns( // reaches exactly one timeline). for (const turnId of order) { const turn = byId.get(turnId)!; + const turnMessages = messagesByTurn.get(turnId) ?? []; turn.timeline = buildTurnTimeline( - messagesByTurn.get(turnId) ?? [], - toolItemByUseId, + turnMessages, + toolItemsByTurnId.get(turnId) ?? new Map(), ); turn.tools = timelineTools(turn.timeline); } @@ -837,24 +860,30 @@ export function finalAssistantReplyText(turn: TurnViewModel): string { * there, leaving an orphan tool row and a parent that never took the child's * revision. */ -export function foldShellRunToolActivities( - items: readonly ToolActivityItem[], -): ToolActivityItem[] { +interface ScopedToolActivityItem { + turnId: string; + item: ToolActivityItem; +} + +function foldScopedShellRunToolActivities( + items: readonly ScopedToolActivityItem[], +): ScopedToolActivityItem[] { const ownedRefs = new Set(); - for (const item of items) { + for (const { item } of items) { if (item.toolName === "Bash" && item.result?.kind === "shell_run") ownedRefs.add(item.result.ref); } - const folded: ToolActivityItem[] = []; + const folded: ScopedToolActivityItem[] = []; const parentIndexByRef = new Map(); const childResultsByRef = new Map(); - for (const item of items) { + for (const scopedItem of items) { + const { item } = scopedItem; const result = item.result?.kind === "shell_run" ? item.result : undefined; if (!result || item.toolName === "Bash") { if (result) parentIndexByRef.set(result.ref, folded.length); - folded.push(item); + folded.push(scopedItem); continue; } if (ownedRefs.has(result.ref)) { @@ -864,14 +893,14 @@ export function foldShellRunToolActivities( if (item.toolName === "Read" || item.toolName === "StopBackgroundTask") continue; } - folded.push(item); + folded.push(scopedItem); } for (const [ref, results] of childResultsByRef) { const index = parentIndexByRef.get(ref)!; const parent = folded[index]!; let current = - parent.result?.kind === "shell_run" ? parent.result : undefined; + parent.item.result?.kind === "shell_run" ? parent.item.result : undefined; let changed = false; for (const result of results) { const merged = mergeShellRunStateWithDiagnostics( @@ -884,18 +913,37 @@ export function foldShellRunToolActivities( changed = true; } } - if (changed && current) folded[index] = { ...parent, result: current }; + if (changed && current) + folded[index] = { ...parent, item: { ...parent.item, result: current } }; } return folded; } +export function foldShellRunToolActivities( + items: readonly ToolActivityItem[], +): ToolActivityItem[] { + return foldScopedShellRunToolActivities( + items.map((item) => ({ turnId: "", item })), + ).map(({ item }) => item); +} + function foldShellRunTurns( turns: readonly TurnViewModel[], ): readonly TurnViewModel[] { - return projectTurnTools( - turns, - foldShellRunToolActivities(turns.flatMap((turn) => turn.tools)), + const folded = foldScopedShellRunToolActivities( + turns.flatMap((turn) => + turn.tools.map((item) => ({ turnId: turn.turnId, item })), + ), + ); + const toolsByTurnId = new Map(); + for (const { turnId, item } of folded) { + const turnTools = toolsByTurnId.get(turnId); + if (turnTools) turnTools.push(item); + else toolsByTurnId.set(turnId, [item]); + } + return turns.map( + (turn) => projectTurnTools([turn], toolsByTurnId.get(turn.turnId) ?? [])[0]!, ); }