You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by zh...@apache.org on 2026/10/13 04:33:57 UTC

(maka) branch main updated: fix(runtime): preserve client capability tool outcomes (#5458)

This is an automated email from the ASF dual-hosted git repository.

me2seeks pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git


The following commit(s) were added to refs/heads/main by this push:
     new 32b1750487 fix(runtime): preserve client capability tool outcomes (#5458)
32b1750487 is described below

commit 32b1750487ad2ccee6da5928c5dc8f98851f5bca
Author: testikun <bu...@gmail.com>
AuthorDate: Tue Oct 13 13:33:53 2026 +0900

    fix(runtime): preserve client capability tool outcomes (#5458)
    
    Carry success, error and aborted outcomes through native Client Capability execution, Host settlement, durable events and transcript replay. Preserve public Computer Use failures and scope reused call IDs by append order.
    
    Advance the Host compatibility epoch to 220 on current main. Clean build, workspace typechecks, affected suites and exact-head hosted CI passed.
    
    Co-authored-by: testikun <32...@users.noreply.github.com>
    Generated-by: OpenAI Codex
---
 .../src/main/__tests__/browser-tools.test.ts       |   1 +
 .../__tests__/mcp-form-host-integration.test.ts    |   4 +-
 .../src/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 +++
 .../src/main/__tests__/workhub-runtime.test.ts     |   2 +-
 .../src/main/computer-use-real-model-policy.ts     |   5 +
 .../src/main/runtime-host-native-capabilities.ts   |  16 ++-
 .../src/__tests__/acp-session-event-mapper.test.ts |  26 ++++
 .../__tests__/mcp-form-host-integration.test.ts    |  10 +-
 packages/cli/src/__tests__/pi-transcript.test.ts   |  87 +++++++++++-
 ...untime-host-capability-provider-command.test.ts |  10 +-
 .../__tests__/tui-mcp-local-integration.test.ts    |   2 +
 .../__tests__/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 +++--
 .../__tests__/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 ++
 .../src/__tests__/authenticated-websocket.test.ts  |   5 +-
 ...client-capability-admission-integration.test.ts |  10 +-
 .../__tests__/client-capability-channel.test.ts    |  38 ++++--
 .../client-capability-coordinator.test.ts          |  12 +-
 .../client-capability-interaction-broker.test.ts   |  10 +-
 .../client-capability-invocation-broker.test.ts    |  10 +-
 .../__tests__/client-capability-protocol.test.ts   |  88 ++++++++++--
 .../client-capability-session-scope.test.ts        |   2 +-
 .../src/__tests__/client-capability-uds.test.ts    |  32 ++++-
 .../src/__tests__/execution-composition.test.ts    |   1 +
 .../__tests__/execution-host-continuation.test.ts  |   2 +-
 .../__tests__/execution-model-composition.test.ts  |   3 +
 .../src/__tests__/oauth-coordinator.test.ts        |   2 +-
 .../src/__tests__/root-turn-coordinator.test.ts    |  10 +-
 .../session-continuity-coordinator.test.ts         |  32 +++++
 .../src/__tests__/session-transcript-pager.test.ts |   2 +
 .../runtime-host/src/adapter/session-projector.ts  |   4 +-
 .../src/client/client-capability-channel.ts        |   1 +
 .../runtime-host/src/protocol/client-capability.ts |  13 +-
 packages/runtime-host/src/protocol/index.ts        |   5 +-
 .../src/protocol/session-continuity.ts             |   8 +-
 .../src/server/client-capability-coordinator.ts    |   3 +
 .../src/server/session-continuity-coordinator.ts   |   3 +-
 .../src/server/shared-session-transcript.ts        |   1 +
 .../src/__tests__/computer-use-tools.test.ts       |  54 ++++++++
 .../src/__tests__/computer-use-wait-for.test.ts    |  10 +-
 .../src/__tests__/runtime-event-read-model.test.ts |  30 +++++
 .../src/__tests__/session-trace-projection.test.ts |  48 +++++++
 .../tool-runtime-durable-boundary.test.ts          |  88 ++++++++++++
 .../src/__tests__/tool-runtime-settlement.test.ts  |  36 +++++
 packages/runtime/src/computer-use-tools.ts         | 150 +++++++++++++++++----
 packages/runtime/src/mcp-tools.ts                  |   5 +
 packages/runtime/src/runtime-event-backfill.ts     |   1 +
 packages/runtime/src/runtime-event-read-model.ts   |   1 +
 .../runtime/src/session-event-runtime-mapper.ts    |   1 +
 packages/runtime/src/session-trace-projection.ts   |   7 +-
 packages/runtime/src/tool-runtime.ts               |  90 ++++++++++---
 .../src/__tests__/sqlite-runtime-store.test.ts     |  30 +++++
 .../ui/src/__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 6ecce3c0e2..8af1becfe2 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 cf797e6aa9..c41897a0b1 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 7d14c551ed..ff5837712e 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 f27f432c38..50f08d4601 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 6bca312df3..6af1ddbf37 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 1b39a27bc0..153208b05b 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 e112dc953b..b4b8cd15e3 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 ef396aeceb..fcf6c7a3b9 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 fdaf0fe129..a68909f3d5 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<ClientCapabilityCallResult> {
+  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 b1d1457ab5..c7e707c08d 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 a8e495f734..6a076ce757 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 5fe26cac94..f252eb1eb2 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 25fe4e269b..2650b08603 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 aab5ec79ff..438dca61a2 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 b643e24b7b..6a8ab19860 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 27abf8db67..f30fc7fd6d 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<void> {
     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<void> {
     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 0a887c3862..f645d039b5 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 5d5199b222..4b45d0112e 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<StoredMessage, { type: 'tool_result' }> =>
-          message.type === 'tool_result',
-      )
-      .map((message) => [message.toolUseId, message]),
-  );
+  const latestCallsByUseId = new Map<string, Extract<StoredMessage, { type: 'tool_call' }>>();
+  const resultsByCall = new Map<
+    Extract<StoredMessage, { type: 'tool_call' }>,
+    Extract<StoredMessage, { type: 'tool_result' }>
+  >();
+  // 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 cb0b4be59d..863ba66d16 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<StoredMessage>(value));
 }
diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts
index 40e107ebb3..f0f5645a21 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 dac38f30fa..e27aae0f97 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 53e0dcbaa0..3e5ade5b7b 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<RuntimeEventFunctionCallCo
 );
 const FUNCTION_RESPONSE_CONTENT_SHAPE = defineObjectShape<RuntimeEventFunctionResponseContent>()(
   ['kind', 'id', 'name', 'result'],
-  ['isError', 'providerExecuted', 'providerOutput', 'modelProjection'],
+  ['isError', 'outcome', 'providerExecuted', 'providerOutput', 'modelProjection'],
 );
 const ERROR_CONTENT_SHAPE = defineObjectShape<RuntimeEventErrorContent>()(
   ['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 71a624d810..b2f6966cd0 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<string, unknown>): 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 ca905c4662..cf9ad0e031 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<ToolResultMessage>()(
   ['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 5c06c9404d..345354c7c7 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 22316b809b..1b343b035b 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 36d69577d5..fae0afe5f9 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 81e3c2b28d..12a902fb03 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 06aae43035..e742666643 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<string, unknown> {
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 31e23f4d73..6166737bf3 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 01bab875c9..c74297c0b5 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 22f16a6d5a..2ac0d41abe 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 f9f63375b7..7549304e50 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 468da0389d..3b3ecd7d09 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 67d7a20b3a..501ae0b66b 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 9c54e88422..440758b83c 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 5bfcbdd969..97f85589b5 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 e8aca9060f..18b1918685 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 1501ab6304..eb7a17472e 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 d1fd3492a0..fdc53f2fe8 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 b46a0f03eb..8a5c0516fc 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 22b1b2a1e7..c36d07e32a 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 0ab0f4a16c..61248d0e62 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 49f3522ff8..85253c8b1a 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 1f365745e1..a2b5157ce9 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 f754a0a2ca..48b0c20bbf 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 a721cdc4dd..e5ca3e3e42 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 a9b6c45a6b..628fc6c09b 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 243512859f..ced768c7e6 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 c79f2c4845..b6d876e5e9 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 97b7f8c75e..fc4979d1c4 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<string, unknown>,
   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 9d3cb1760b..fa6ddffb10 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 99985ede86..d0697f0b51 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 aefc6d2551..01bd89f044 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 9bbb6eb4d4..0633090b8c 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 b98af05170..15e4ea3157 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<string, CuObservedElement>;
     /** 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<string, SessionObservationRecord>();
   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<CuRunResult>;
   }): 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<ComputerParams, ComputerToolResult> = {
+  const rawTool: MakaTool<ComputerParams, ComputerToolResult> = {
     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<ComputerToolResult> => {
-      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<ComputerToolResult> & { error?: unknown };
+      const o = (output ?? {}) as Partial<ComputerToolResult> & {
+        error?: unknown;
+      };
       const text =
         typeof o.modelText === 'string'
           ? redactSecrets(o.modelText)
@@ -2830,6 +2923,11 @@ export function buildComputerUseTools(deps: {
       };
     },
   };
+  const tool: MakaTool<ComputerParams, ComputerToolResult> = {
+    ...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 3b6021c9d6..11ae03ca2a 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 be32b644d6..059ea60b60 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 a15cf9ffac..502d45c7dd 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 b7cdb2636f..a49d0bc94a 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 204f1c53a1..84cd415057 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 759b3c3f5e..eb9983a21f 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<P = any, R = unknown> {
    * settlement instead of detaching, so late side effects cannot outlive `exec`.
    */
   impl: (args: P, ctx: MakaToolContext) => Promise<R> | 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<void> {
     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<string, unknown>;
+  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 d490e77b3c..34b10ad66a 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 6e265d8c53..30dcb5f0ed 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 115cce2a09..ce12d446ab 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 8e5783d4fd..a47a806810 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 57ce751495..0d59e37245 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<StoredMessage, { type: "tool_call" }>
+  >();
+  const resultsByCall = new Map<
+    Extract<StoredMessage, { type: "tool_call" }>,
+    Extract<StoredMessage, { type: "tool_result" }>
+  >();
+  // 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<StoredMessage, { type: "tool_result" }>,
 ): 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<string, ToolActivityItem>(
-    foldShellRunToolActivities(materializeTools(messages)).map((tool) => [
-      tool.toolUseId,
-      tool,
-    ]),
+  const toolCalls = messages.filter(
+    (message): message is Extract<StoredMessage, { type: "tool_call" }> =>
+      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<string, Map<string, ToolActivityItem>>();
+  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<string>();
-  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<string, number>();
   const childResultsByRef = new Map<string, ShellRunToolResult[]>();
 
-  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<string, ToolActivityItem[]>();
+  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]!,
   );
 }
 


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org