You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/09/13 08:59:41 UTC

(maka) branch refactor/tool-results-unified-read updated (ecaca76c00 -> 428919b9cc)

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

Astro-Han pushed a change to branch refactor/tool-results-unified-read
in repository https://gitbox.apache.org/repos/asf/maka.git


    from ecaca76c00 fix(runtime): validate Read before backend selection
     new 15fa70eb32 fix(runtime): page terminal failure details with output
     new 428919b9cc fix(runtime): retain foreground Bash output for bounded Read recovery

The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../core/src/durable-tool-result-projection.ts     |   4 +-
 packages/core/src/tool-result-archive-evidence.ts  |   5 +-
 .../src/__tests__/execution-artifacts.test.ts      | 161 ++++++++++++++-------
 .../__tests__/session-transcript-reader.test.ts    | 117 ++++++++++++++-
 .../src/server/session-transcript-reader.ts        |  19 ++-
 .../runtime/src/__tests__/ai-sdk-backend.test.ts   | 143 +++++++++++-------
 .../runtime/src/__tests__/bash-tail-buffer.test.ts |  57 +++-----
 .../runtime/src/__tests__/builtin-tools.test.ts    |  33 +----
 .../durable-tool-result-projection.test.ts         |   2 +-
 packages/runtime/src/__tests__/read-page.test.ts   |  44 +++++-
 packages/runtime/src/__tests__/shell-exec.test.ts  |  13 +-
 .../src/__tests__/shell-run-manager.test.ts        |  11 +-
 .../src/__tests__/shell-run-tool-result.test.ts    |  15 +-
 packages/runtime/src/__tests__/shell-tools.test.ts |  19 ++-
 packages/runtime/src/bash-tail-buffer.ts           |  67 +++------
 packages/runtime/src/pipe-tail-collector.ts        |  15 +-
 packages/runtime/src/read-page.ts                  |  26 ++--
 packages/runtime/src/shell-exec.ts                 |  38 +----
 packages/runtime/src/shell-run-manager.ts          |   2 +-
 packages/runtime/src/shell-run-tool-result.ts      |   2 +-
 packages/runtime/src/shell-tools.ts                |  18 +--
 .../runtime/src/tool-result-archive-resource.ts    |   3 +-
 .../__tests__/tool-result-archive-evidence.test.ts |   5 +-
 23 files changed, 499 insertions(+), 320 deletions(-)


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


(maka) 02/02: fix(runtime): retain foreground Bash output for bounded Read recovery

Posted by as...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

Astro-Han pushed a commit to branch refactor/tool-results-unified-read
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 428919b9cc21db9c2e5fcbf2bf8163ba6199ccb4
Author: AstroHan <le...@outlook.com>
AuthorDate: Sun Sep 13 16:58:36 2026 +0800

    fix(runtime): retain foreground Bash output for bounded Read recovery
---
 .../core/src/durable-tool-result-projection.ts     |   4 +-
 packages/core/src/tool-result-archive-evidence.ts  |   5 +-
 .../src/__tests__/execution-artifacts.test.ts      | 161 ++++++++++++++-------
 .../__tests__/session-transcript-reader.test.ts    | 117 ++++++++++++++-
 .../src/server/session-transcript-reader.ts        |  19 ++-
 .../runtime/src/__tests__/ai-sdk-backend.test.ts   | 143 +++++++++++-------
 .../runtime/src/__tests__/bash-tail-buffer.test.ts |  57 +++-----
 .../runtime/src/__tests__/builtin-tools.test.ts    |  33 +----
 .../durable-tool-result-projection.test.ts         |   2 +-
 packages/runtime/src/__tests__/shell-exec.test.ts  |  13 +-
 .../src/__tests__/shell-run-manager.test.ts        |  11 +-
 .../src/__tests__/shell-run-tool-result.test.ts    |  15 +-
 packages/runtime/src/__tests__/shell-tools.test.ts |  19 ++-
 packages/runtime/src/bash-tail-buffer.ts           |  67 +++------
 packages/runtime/src/pipe-tail-collector.ts        |  15 +-
 packages/runtime/src/shell-exec.ts                 |  38 +----
 packages/runtime/src/shell-run-manager.ts          |   2 +-
 packages/runtime/src/shell-run-tool-result.ts      |   2 +-
 packages/runtime/src/shell-tools.ts                |  18 +--
 .../runtime/src/tool-result-archive-resource.ts    |   3 +-
 .../__tests__/tool-result-archive-evidence.test.ts |   5 +-
 21 files changed, 443 insertions(+), 306 deletions(-)

diff --git a/packages/core/src/durable-tool-result-projection.ts b/packages/core/src/durable-tool-result-projection.ts
index 3a5a397597..905fbe4441 100644
--- a/packages/core/src/durable-tool-result-projection.ts
+++ b/packages/core/src/durable-tool-result-projection.ts
@@ -23,7 +23,9 @@ import { hasExactShape, isRecord } from './record-schema.js';
 import { serializedByteLength } from './serialized-byte-length.js';
 
 export const DURABLE_TOOL_RESULT_PROJECTION_VERSION = 1 as const;
-export const DURABLE_TOOL_RESULT_PROJECTION_MAX_BYTES = 256 * 1024;
+// Storage capacity, not a provider context budget. Two executor-retained Bash
+// streams can require 12 MiB after JSON escaping; Read bounds their presentation.
+export const DURABLE_TOOL_RESULT_PROJECTION_MAX_BYTES = 16 * 1024 * 1024;
 export const DURABLE_TOOL_RESULT_PROJECTION_MAX_PARTS = 64;
 export const DURABLE_TOOL_RESULT_PROJECTION_MAX_JSON_DEPTH = 32;
 export const DURABLE_TOOL_RESULT_PROJECTION_MAX_JSON_NODES = 20_000;
diff --git a/packages/core/src/tool-result-archive-evidence.ts b/packages/core/src/tool-result-archive-evidence.ts
index f75f1dcd08..26f2be14a4 100644
--- a/packages/core/src/tool-result-archive-evidence.ts
+++ b/packages/core/src/tool-result-archive-evidence.ts
@@ -19,6 +19,7 @@
 
 import type { RuntimeEvent } from './runtime-event.js';
 import type { AgentRunEvent } from './agent-run.js';
+import { DURABLE_TOOL_RESULT_PROJECTION_MAX_BYTES } from './durable-tool-result-projection.js';
 
 /** Complete, bounded evidence for one target, never a partial transition history. */
 export type ToolResultArchiveEvidence =
@@ -40,4 +41,6 @@ export interface ToolResultArchiveEvidenceReader {
 }
 
 export const TOOL_RESULT_ARCHIVE_EVIDENCE_MAX_TRANSITIONS = 64;
-export const TOOL_RESULT_ARCHIVE_EVIDENCE_MAX_BYTES = 2 * 1024 * 1024;
+// Include event identity and transition overhead around the stored projection.
+export const TOOL_RESULT_ARCHIVE_EVIDENCE_MAX_BYTES =
+  DURABLE_TOOL_RESULT_PROJECTION_MAX_BYTES + 128 * 1024;
diff --git a/packages/runtime-host/src/__tests__/execution-artifacts.test.ts b/packages/runtime-host/src/__tests__/execution-artifacts.test.ts
index 7fc0863ae4..eb81329b52 100644
--- a/packages/runtime-host/src/__tests__/execution-artifacts.test.ts
+++ b/packages/runtime-host/src/__tests__/execution-artifacts.test.ts
@@ -27,7 +27,13 @@ import {
 } from '@maka/core/model-projection-transition';
 import { buildLedgerArchivedToolResultPlaceholder } from '@maka/runtime/tool-result-archive';
 import type { DurableToolResultProjection } from '@maka/core/durable-tool-result-projection';
-import { parseToolResultArchiveResourceRef } from '@maka/runtime/tool-result-archive-resource';
+import {
+  parseToolResultArchiveResourceRef,
+  readToolResultArchiveResource,
+} from '@maka/runtime/tool-result-archive-resource';
+import { shapeTerminalResult } from '@maka/runtime/shell-tools';
+import { readPageSchema, READ_PAGE_MAX_CHARS } from '@maka/runtime/read-page';
+import { TOOL_RESULT_ARCHIVE_EVIDENCE_MAX_BYTES } from '@maka/core/tool-result-archive-evidence';
 import { mkdir, mkdtemp, rm, stat, truncate, writeFile } from 'node:fs/promises';
 import { tmpdir } from 'node:os';
 import { join } from 'node:path';
@@ -37,7 +43,10 @@ import {
   openInteractiveArtifactStoreForWrite,
   createReadImageSnapshotPlanner,
 } from '@maka/storage/artifact-stores';
-import { encodeDurableToolResultOutputWithArtifacts } from '@maka/runtime/durable-tool-result-projection';
+import {
+  encodeDurableToolResultOutput,
+  encodeDurableToolResultOutputWithArtifacts,
+} from '@maka/runtime/durable-tool-result-projection';
 import { durableProjectionToToolResultOutput } from '@maka/runtime/durable-tool-result-projection';
 import { deferred } from '@maka/core/test-only/async-primitives';
 import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority';
@@ -45,8 +54,10 @@ import { createHostExecutionArtifactServices } from '../server/execution-artifac
 import { restoreArtifactV1Shape } from './fixtures/artifact-v1.js';
 import { SessionAdmissionGate } from '../server/session-admission-gate.js';
 
-for (const largeImage of [false, true]) {
-  test(`production archives survive reopen (large raw MCP image: ${largeImage})`, async () => {
+for (const scenario of ['text', 'large raw MCP image', 'executor-sized Bash'] as const) {
+  test(`production archives survive reopen (${scenario})`, async () => {
+    const largeImage = scenario === 'large raw MCP image';
+    const largeBash = scenario === 'executor-sized Bash';
     const root = await mkdtemp(join(tmpdir(), 'maka-ledger-archive-host-'));
     const owner = await tryAcquireInteractiveRootOwner(
       await resolveStorageRoot({ path: root, kind: 'interactive' }),
@@ -56,19 +67,35 @@ for (const largeImage of [false, true]) {
     let evidence = await openToolResultArchiveEvidenceReader(owner.lease);
     const db = new DatabaseSync(join(root, 'runtime.sqlite'));
     try {
-      const projection: DurableToolResultProjection = largeImage
-        ? {
-            version: 1,
-            kind: 'content',
-            parts: [
-              {
-                kind: 'artifact',
-                mediaType: 'image/png',
-                ref: { kind: 'session_file', sessionId: 'session', relativePath: 'mcp-image' },
-              },
-            ],
-          }
-        : { version: 1, kind: 'text', text: 'durable ledger body' };
+      // Control characters exercise JSON's worst-case six-byte escaping.
+      const stream = `${'\u0001'.repeat(127)}\n`.repeat(8191);
+      const bash = largeBash
+        ? shapeTerminalResult({
+            cwd: root,
+            command: 'synthetic bounded output',
+            result: {
+              stdout: `FRONT\n${stream}TAIL`,
+              stderr: `ERROR_FRONT\n${stream}ERROR_TAIL`,
+              exitCode: 7,
+              stdoutTruncated: true,
+            },
+          })
+        : undefined;
+      const projection: DurableToolResultProjection = bash
+        ? encodeDurableToolResultOutput({ type: 'json', value: bash as never }, 'session')
+        : largeImage
+          ? {
+              version: 1,
+              kind: 'content',
+              parts: [
+                {
+                  kind: 'artifact',
+                  mediaType: 'image/png',
+                  ref: { kind: 'session_file', sessionId: 'session', relativePath: 'mcp-image' },
+                },
+              ],
+            }
+          : { version: 1, kind: 'text', text: 'durable ledger body' };
       const output = durableProjectionToToolResultOutput(projection);
       assert.ok('value' in output);
       const serializedResult = JSON.stringify(output.value);
@@ -86,18 +113,20 @@ for (const largeImage of [false, true]) {
         content: {
           kind: 'function_response',
           id: 'call',
-          name: 'Read',
-          result: largeImage
-            ? {
-                content: [
-                  {
-                    type: 'image',
-                    mimeType: 'image/png',
-                    data: Buffer.alloc(2 * 1024 * 1024).toString('base64'),
-                  },
-                ],
-              }
-            : 'raw execution body',
+          name: bash ? 'Bash' : 'Read',
+          result:
+            bash ??
+            (largeImage
+              ? {
+                  content: [
+                    {
+                      type: 'image',
+                      mimeType: 'image/png',
+                      data: Buffer.alloc(2 * 1024 * 1024).toString('base64'),
+                    },
+                  ],
+                }
+              : 'raw execution body'),
           modelProjection: projection,
         },
       };
@@ -119,28 +148,32 @@ for (const largeImage of [false, true]) {
         runtimeEventId: 'response',
       });
       assert.ok(projectedEvidence.ok);
-      assert.ok(projectedEvidence.storedBytes! < 4096);
+      assert.ok(projectedEvidence.storedBytes! < TOOL_RESULT_ARCHIVE_EVIDENCE_MAX_BYTES);
+      if (bash) assert.ok(projectedEvidence.storedBytes! > 12_000_000);
+      else assert.ok(projectedEvidence.storedBytes! < 4096);
       assert.equal(
         projectedEvidence.event.content?.kind === 'function_response'
           ? projectedEvidence.event.content.result
           : undefined,
         null,
       );
-      const old = await artifacts.create({
-        id: 'legacy-archive',
-        sessionId: 'session',
-        turnId: 'turn',
-        name: 'legacy.json',
-        kind: 'file',
-        content: serializedResult,
-        source: 'tool_result_archive',
-      });
+      const old = bash
+        ? undefined
+        : await artifacts.create({
+            id: 'legacy-archive',
+            sessionId: 'session',
+            turnId: 'turn',
+            name: 'legacy.json',
+            kind: 'file',
+            content: serializedResult,
+            source: 'tool_result_archive',
+          });
       const input = {
         sessionId: 'session',
         runtimeEventId: 'response',
         turnId: 'turn',
         toolCallId: 'call',
-        toolName: 'Read',
+        toolName: bash ? 'Bash' : 'Read',
         serializedResult,
         bodySha256,
         originalBytes: Buffer.byteLength(serializedResult),
@@ -161,7 +194,10 @@ for (const largeImage of [false, true]) {
       const prepared = await services.toolResultArchive.services.archiveToolResult(input);
       assert.ok(prepared?.ledger);
       assert.equal(typeof prepared.commitTransition, 'function');
-      assert.equal((await artifacts.listPage('session', { offset: 0, limit: 10 })).total, 1);
+      assert.equal(
+        (await artifacts.listPage('session', { offset: 0, limit: 10 })).total,
+        bash ? 0 : 1,
+      );
       const placeholder = buildLedgerArchivedToolResultPlaceholder({ ...input, storage: 'ledger' });
       const transition = buildModelProjectionTransition({
         sessionId: 'session',
@@ -169,7 +205,7 @@ for (const largeImage of [false, true]) {
           runtimeEventId: 'response',
           part: 'tool_result',
           toolCallId: 'call',
-          toolName: 'Read',
+          toolName: bash ? 'Bash' : 'Read',
         },
         sourceProjection: projection,
         replacement: { version: 1, kind: 'json', value: placeholder as never },
@@ -237,16 +273,37 @@ for (const largeImage of [false, true]) {
         }),
         { ok: true, serializedResult },
       );
-      assert.deepEqual(
-        await services.toolResultArchive.services.readArchivedToolResultResource({
-          artifactId: old.id,
-          bodySha256,
-          originalBytes: input.originalBytes,
-          sessionId: 'session',
-          maxBytes: input.originalBytes,
-        }),
-        { ok: true, serializedResult },
-      );
+      if (bash) {
+        const read = async (offset: number) => {
+          const page = readPageSchema.parse(
+            await readToolResultArchiveResource(services.toolResultArchive.services, 'session', {
+              path: 'maka://runtime/tool-results/response',
+              offset,
+              limit: 1,
+            }),
+          );
+          assert.ok(JSON.stringify(page).length <= READ_PAGE_MAX_CHARS);
+          assert.equal(page.metadata?.exitCode, 7);
+          assert.equal(page.metadata?.status, 'failed');
+          assert.equal(page.metadata?.stdoutTruncated, true);
+          return page.content;
+        };
+        assert.equal(await read(0), 'FRONT');
+        assert.equal(await read(8192), 'TAIL');
+        assert.equal(await read(8193), 'ERROR_FRONT');
+        assert.equal(await read(16385), 'ERROR_TAIL');
+      }
+      if (old)
+        assert.deepEqual(
+          await services.toolResultArchive.services.readArchivedToolResultResource({
+            artifactId: old.id,
+            bodySha256,
+            originalBytes: input.originalBytes,
+            sessionId: 'session',
+            maxBytes: input.originalBytes,
+          }),
+          { ok: true, serializedResult },
+        );
     } finally {
       db.close();
       evidence.close();
diff --git a/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts b/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts
index 8756885c32..a727ccb446 100644
--- a/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts
+++ b/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts
@@ -31,6 +31,9 @@ import {
   type StoredMessage,
 } from '@maka/core/session';
 import { projectRuntimeEventsToStoredMessages } from '@maka/runtime/runtime-event-read-model';
+import { encodeDurableToolResultOutput } from '@maka/runtime/durable-tool-result-projection';
+import { shapeTerminalResult } from '@maka/runtime/shell-tools';
+import { DURABLE_TOOL_RESULT_PROJECTION_MAX_BYTES } from '@maka/core/durable-tool-result-projection';
 import { foldTurnContribution } from '@maka/storage/session-message-projection';
 import type { SessionTurnContribution } from '@maka/storage/execution-stores';
 import {
@@ -38,7 +41,10 @@ import {
   openInteractiveExecutionStoresForWrite,
 } from '@maka/storage/execution-stores';
 import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority';
-import { createSessionTranscriptReader } from '../server/session-transcript-reader.js';
+import {
+  ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES,
+  createSessionTranscriptReader,
+} from '../server/session-transcript-reader.js';
 
 for (const coordination of [false, true])
   test(`keeps ${coordination ? 'WorkHub' : 'ordinary'} durable history separate from the canonical active overlay`, async () => {
@@ -233,6 +239,59 @@ for (const coordination of [false, true])
           refs: { providerEventId: 'assistant-2' },
         }),
       );
+      let largeBash: ReturnType<typeof shapeTerminalResult> | undefined;
+      if (!coordination) {
+        const stream = `${'\u0001'.repeat(127)}\n`.repeat(8_191);
+        largeBash = shapeTerminalResult({
+          cwd: capability.canonicalPath,
+          command: 'synthetic bounded output',
+          result: {
+            stdout: `FRONT\n${stream}TAIL`,
+            stderr: `ERROR_FRONT\n${stream}ERROR_TAIL`,
+            exitCode: 7,
+          },
+        });
+        const modelProjection = encodeDurableToolResultOutput(
+          { type: 'json', value: largeBash as never },
+          session.id,
+        );
+        assert.equal(modelProjection.kind, 'json');
+        await stores.runtimeEventStore.appendRuntimeEvent(
+          session.id,
+          'run-1',
+          runtimeEvent(session.id, {
+            id: 'large-bash-call',
+            ts: 11,
+            role: 'model',
+            author: 'agent',
+            content: {
+              kind: 'function_call',
+              id: 'large-bash',
+              name: 'Bash',
+              args: { command: 'synthetic bounded output' },
+            },
+            refs: { toolCallId: 'large-bash' },
+          }),
+        );
+        const resultEvent = runtimeEvent(session.id, {
+          id: 'large-bash-result',
+          ts: 12,
+          role: 'tool',
+          author: 'tool',
+          content: {
+            kind: 'function_response',
+            id: 'large-bash',
+            name: 'Bash',
+            result: largeBash,
+            modelProjection,
+          },
+          refs: { toolCallId: 'large-bash' },
+        });
+        const resultEventBytes = Buffer.byteLength(JSON.stringify(resultEvent), 'utf8');
+        assert.ok(resultEventBytes > DURABLE_TOOL_RESULT_PROJECTION_MAX_BYTES);
+        assert.ok(resultEventBytes > 23 * 1024 * 1024);
+        await stores.runtimeEventStore.appendRuntimeEvent(session.id, 'run-1', resultEvent);
+      }
 
       const read = createSessionTranscriptReader({
         stores,
@@ -246,7 +305,7 @@ for (const coordination of [false, true])
       });
 
       assert.deepEqual(
-        messages.map((message) => ({ type: message.type, id: message.id })),
+        messages.slice(0, 4).map((message) => ({ type: message.type, id: message.id })),
         [
           { type: 'user', id: 'user-1' },
           { type: 'assistant', id: 'assistant-1' },
@@ -254,21 +313,24 @@ for (const coordination of [false, true])
           { type: 'assistant', id: 'assistant-3' },
         ],
       );
-      const firstAssistant = messages.at(-3);
+      assert.equal(messages.length, coordination ? 4 : 6);
+      const firstAssistant = messages[1];
       assert.equal(firstAssistant?.type, 'assistant');
       if (firstAssistant?.type === 'assistant') {
         assert.equal(firstAssistant.text, 'still streaming');
         assert.equal(firstAssistant.thinking?.text, 'deep thought');
       }
-      const thinkingOnly = messages.at(-2);
+      const thinkingOnly = messages[2];
       assert.equal(thinkingOnly?.type, 'assistant');
       if (thinkingOnly?.type === 'assistant') {
         assert.equal(thinkingOnly.text, '');
         assert.equal(thinkingOnly.thinking?.text, 'still reasoning');
       }
-      const completed = messages.at(-1);
-      assert.equal(completed?.type, 'assistant');
-      if (completed?.type === 'assistant') assert.equal(completed.text, 'final text');
+      const completedAssistant = messages[3];
+      assert.equal(completedAssistant?.type, 'assistant');
+      if (completedAssistant?.type === 'assistant')
+        assert.equal(completedAssistant.text, 'final text');
+      if (largeBash) assertLargeBashResult(messages, largeBash);
 
       const durable = await read.readDurablePage(session.id, {
         direction: 'older',
@@ -287,6 +349,27 @@ for (const coordination of [false, true])
           { type: 'user', id: 'user-0' },
         ],
       );
+      if (largeBash) {
+        await stores.runtimeEventStore.appendRuntimeEvent(
+          session.id,
+          'run-1',
+          runtimeEvent(session.id, {
+            id: 'terminal-1',
+            ts: 13,
+            status: 'failed',
+            actions: { endInvocation: true },
+          }),
+        );
+        const recovered = await read.readDurableRecords(session.id, {
+          direction: 'older',
+          maxStoredBytes: ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES,
+          maxMessages: 16,
+        });
+        assertLargeBashResult(
+          recovered.records.map(({ message }) => message),
+          largeBash,
+        );
+      }
     } finally {
       await owner.close();
       await rm(base, { recursive: true, force: true });
@@ -786,6 +869,26 @@ function runtimeEvent(sessionId: string, overrides: Partial<RuntimeEvent>): Runt
   };
 }
 
+function assertLargeBashResult(
+  messages: readonly StoredMessage[],
+  expected: ReturnType<typeof shapeTerminalResult>,
+): void {
+  const result = messages.find(
+    (message) => message.type === 'tool_result' && message.toolUseId === 'large-bash',
+  );
+  assert.ok(result?.type === 'tool_result');
+  assert.ok(
+    Buffer.byteLength(JSON.stringify(result), 'utf8') < ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES,
+  );
+  assert.ok(result.content.kind === 'terminal');
+  assert.equal(result.content.status, 'failed');
+  assert.equal(result.content.exitCode, 7);
+  assert.ok(result.content.output.mode === 'pipes');
+  assert.ok(expected.output.mode === 'pipes');
+  assert.equal(result.content.output.stdout, expected.output.stdout);
+  assert.equal(result.content.output.stderr, expected.output.stderr);
+}
+
 function testInvocation(sessionId: string): RuntimeInvocationRecord {
   return {
     sessionId,
diff --git a/packages/runtime-host/src/server/session-transcript-reader.ts b/packages/runtime-host/src/server/session-transcript-reader.ts
index afb63a0b31..5ec898f90f 100644
--- a/packages/runtime-host/src/server/session-transcript-reader.ts
+++ b/packages/runtime-host/src/server/session-transcript-reader.ts
@@ -18,6 +18,7 @@
  */
 
 import type { RuntimeEvent } from '@maka/core/runtime-event';
+import { DURABLE_TOOL_RESULT_PROJECTION_MAX_BYTES } from '@maka/core/durable-tool-result-projection';
 import { readRunInvocation } from '@maka/core/runtime-event-store';
 import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation';
 import { runtimeHandoffPause } from '@maka/core/runtime-handoff';
@@ -57,14 +58,16 @@ const EVENT_SEQUENCE_STRIDE = 8;
 export const ACTIVE_TRANSCRIPT_OVERLAY_MAX_MESSAGES = SESSION_TRANSCRIPT_OVERLAY_MAX_MESSAGES;
 export const ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES = 16 * 1024 * 1024;
 const ACTIVE_TRANSCRIPT_SOURCE_MAX_EVENTS = ACTIVE_TRANSCRIPT_OVERLAY_MAX_MESSAGES * 2;
+// One RuntimeEvent can carry both the raw Tool Result and its durable model projection.
+const TRANSCRIPT_SOURCE_MAX_BYTES = DURABLE_TOOL_RESULT_PROJECTION_MAX_BYTES * 2 + 256 * 1024;
 const ACTIVE_TRANSCRIPT_SCAN_BATCH_MAX_BYTES = 256 * 1024;
 /**
- * What one durable page may read of a Turn, matching the bound the live
- * overlay already holds for a run. A Turn past it is refused rather than
- * half-projected: a prefix of a Turn is not a smaller transcript of it.
+ * What one durable page may read of a Turn, matching the source bound used to
+ * build a live overlay. A Turn past it is refused rather than half-projected:
+ * a prefix of a Turn is not a smaller transcript of it.
  */
 const DURABLE_TRANSCRIPT_TURN_MAX_EVENTS = ACTIVE_TRANSCRIPT_SOURCE_MAX_EVENTS;
-const DURABLE_TRANSCRIPT_TURN_MAX_BYTES = ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES;
+const DURABLE_TRANSCRIPT_TURN_MAX_BYTES = TRANSCRIPT_SOURCE_MAX_BYTES;
 /** Turns per storage round trip: one, so a page loads no Turn it cannot use. */
 const TRANSCRIPT_TURN_SCAN_LIMIT = 1;
 /**
@@ -623,11 +626,11 @@ async function readActiveRuntimeEvents(
     runId,
     {
       maxBatchBytes: ACTIVE_TRANSCRIPT_SCAN_BATCH_MAX_BYTES,
-      maxRecordBytes: ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES,
+      maxRecordBytes: TRANSCRIPT_SOURCE_MAX_BYTES,
       maxImmutableRecords: ACTIVE_TRANSCRIPT_SOURCE_MAX_EVENTS,
-      maxImmutableBytes: ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES,
+      maxImmutableBytes: TRANSCRIPT_SOURCE_MAX_BYTES,
       maxPartialRecords: ACTIVE_TRANSCRIPT_SOURCE_MAX_EVENTS,
-      maxPartialBytes: ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES,
+      maxPartialBytes: TRANSCRIPT_SOURCE_MAX_BYTES,
     },
     (batch) => {
       for (const event of batch) {
@@ -636,7 +639,7 @@ async function readActiveRuntimeEvents(
         if (budget.events > ACTIVE_TRANSCRIPT_SOURCE_MAX_EVENTS) {
           throw new Error('Active RuntimeEvent transcript exceeds its event limit');
         }
-        if (budget.bytes > ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES) {
+        if (budget.bytes > TRANSCRIPT_SOURCE_MAX_BYTES) {
           throw new Error('Active RuntimeEvent transcript exceeds its byte limit');
         }
       }
diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts
index fba8d0a5db..b8bb7a4fa8 100644
--- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts
+++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts
@@ -87,6 +87,7 @@ import { RunTrace } from '../run-trace.js';
 import { decodeModelCallAttempt, type ModelCallAttempt } from '@maka/core/model-call-attempt';
 import { buildLlmHistorySummarizer } from '../history-compact-summarizer.js';
 import { createToolResultArchiveCapability } from '../tool-result-archive-capability.js';
+import { buildForegroundBashTool } from '../shell-tools.js';
 import {
   createTestAiSdkBackend,
   projectedTranscriptOf,
@@ -9056,17 +9057,30 @@ describe('AiSdkBackend usage telemetry', () => {
     assert.equal(usageCheckpoints[0]?.costUsd, undefined);
   });
 
-  test('a pruned tool result is readable again through the tool its placeholder names', async () => {
-    // The whole loop through real dispatch (#2026): the budget prunes an
-    // oversized result, the runtime mints a placeholder naming `ArchiveRead`,
-    // the model calls it with the ref that placeholder carried, and the body
-    // lands back in the conversation. Advertising the decoder is only half the
-    // invariant; the other half is that calling it works from inside the turn.
-    const durable = durableTurnHarness('turn-1', 'read the big file');
-    const largeBody = 'x'.repeat(9_000) + 'ARCHIVED_BODY_SENTINEL';
+  test('a pruned Bash result retains durable output and is readable without rerunning', async () => {
+    const durable = durableTurnHarness('turn-1', 'run the verbose command');
+    const stdoutLines = [
+      'FRONT_SENTINEL',
+      ...Array.from(
+        { length: 4_000 },
+        (_, index) => `line-${String(index).padStart(4, '0')}-${'x'.repeat(36)}`,
+      ),
+      'TAIL_SENTINEL',
+    ];
+    const stdout = stdoutLines.join('\n');
+    const stderr = 'ERR_SENTINEL';
     const store = new Map<string, string>();
     const prompts: unknown[] = [];
+    let executeCalls = 0;
     let streamCalls = 0;
+    const findArchive = (value: any): any => {
+      if (value?.kind === 'maka.archived_tool_result') return value;
+      if (value && typeof value === 'object')
+        for (const child of Object.values(value)) {
+          const found = findArchive(child);
+          if (found) return found;
+        }
+    };
     const model = new MockLanguageModelV4({
       doStream: async ({ prompt }) => {
         streamCalls += 1;
@@ -9084,29 +9098,18 @@ describe('AiSdkBackend usage telemetry', () => {
               },
             },
           ] as LanguageModelV4StreamPart[];
+        const archive = findArchive(prompt);
         const chunks: LanguageModelV4StreamPart[] =
           streamCalls === 1
-            ? call('tool-1', 'Read', { path: 'big.md' })
-            : // The newest completed step is never pruned, so a second call is
-              // what makes the Read result stale enough to be archived.
-              streamCalls === 2
-              ? call('tool-2', 'Bash', { cmd: 'continue' })
+            ? call('tool-1', 'Bash', { command: 'verbose-command' })
+            : streamCalls === 2
+              ? call('tool-2', 'Read', { path: archive.resourceRef, limit: 1 })
               : streamCalls === 3
-                ? call(
-                    'tool-3',
-                    'Read',
-                    (() => {
-                      const findNext = (value: any): any => {
-                        if (value?.kind === 'maka.archived_tool_result') return value.page.next;
-                        if (value && typeof value === 'object')
-                          for (const child of Object.values(value)) {
-                            const found = findNext(child);
-                            if (found) return found;
-                          }
-                      };
-                      return findNext(prompt);
-                    })(),
-                  )
+                ? call('tool-3', 'Read', {
+                    path: archive.resourceRef,
+                    offset: stdoutLines.length,
+                    limit: 1,
+                  })
                 : [
                     { type: 'stream-start', warnings: [] },
                     {
@@ -9128,18 +9131,13 @@ describe('AiSdkBackend usage telemetry', () => {
       modelId: 'mock-model-id',
       modelFactory: () => model,
       tools: [
-        {
-          name: 'Read',
-          description: 'Read description',
-          parameters: z.object({ path: z.string() }),
-          impl: async () => ({ content: largeBody }),
-        },
-        {
-          name: 'Bash',
-          description: 'Bash description',
-          parameters: z.object({ cmd: z.string() }),
-          impl: async () => ({ body: 'small' }),
-        },
+        buildForegroundBashTool({
+          description: 'Run a foreground command.',
+          execute: async () => {
+            executeCalls += 1;
+            return { stdout, stderr, exitCode: 7 };
+          },
+        }),
       ],
       contextBudget: {
         toolResultPrune: { enabled: true },
@@ -9163,19 +9161,58 @@ describe('AiSdkBackend usage telemetry', () => {
 
     for await (const event of backend.send(durable.input())) durable.record(event);
 
-    assert.match(
-      [...store.values()][0] ?? '',
-      /ARCHIVED_BODY_SENTINEL/,
-      'the oversized Read result must have been archived',
-    );
-    const thirdPrompt = JSON.stringify(prompts[2]);
-    assert.doesNotMatch(thirdPrompt, /ARCHIVED_BODY_SENTINEL/);
-    assert.match(thirdPrompt, /maka:\/\/runtime\/tool-results\//);
-    assert.match(
-      JSON.stringify(prompts[3]),
-      /ARCHIVED_BODY_SENTINEL/,
-      'the ArchiveRead result must carry the archived body back into the conversation',
-    );
+    const durableResult = durable.ledger.find(
+      (event) =>
+        event.content?.kind === 'function_response' &&
+        event.content.name === 'Bash' &&
+        event.content.id === 'tool-1',
+    );
+    assert.ok(durableResult?.content?.kind === 'function_response');
+    const terminal = durableResult.content.result as {
+      exitCode: number;
+      status: string;
+      output: { stdout: string; stderr: string; redacted: boolean };
+    };
+    assert.equal(terminal.exitCode, 7);
+    assert.equal(terminal.status, 'failed');
+    assert.ok(terminal.output.stdout.length > 180_000);
+    assert.match(terminal.output.stdout, /^FRONT_SENTINEL/);
+    assert.match(terminal.output.stdout, /TAIL_SENTINEL$/);
+    assert.equal(terminal.output.stdout, stdout);
+    assert.equal(terminal.output.stderr, stderr);
+    assert.equal(terminal.output.redacted, false);
+
+    const secondPrompt = prompts[1];
+    const archive = findArchive(secondPrompt);
+    assert.ok(archive);
+    assert.match(archive.resourceRef, /^maka:\/\/runtime\/tool-results\//);
+    assert.match(archive.page.content, /^FRONT_SENTINEL/);
+    assert.ok(archive.page.content.length < terminal.output.stdout.length);
+    assert.ok(archive.page.next);
+    assert.equal(archive.page.metadata.status, 'failed');
+    assert.equal(archive.page.metadata.exitCode, 7);
+    assert.equal(archive.page.metadata.redacted, false);
+    assert.doesNotMatch(JSON.stringify(secondPrompt), /TAIL_SENTINEL/);
+
+    const findToolResult = (value: any, toolCallId: string): any => {
+      if (value?.toolCallId === toolCallId && value.output !== undefined) return value;
+      if (value && typeof value === 'object')
+        for (const child of Object.values(value)) {
+          const found = findToolResult(child, toolCallId);
+          if (found) return found;
+        }
+    };
+    const frontRead = findToolResult(prompts[2], 'tool-2');
+    assert.ok(frontRead);
+    assert.match(JSON.stringify(frontRead.output), /FRONT_SENTINEL/);
+    assert.match(JSON.stringify(frontRead.output), /"exitCode":7/);
+    const stderrRead = findToolResult(prompts[3], 'tool-3');
+    assert.ok(stderrRead);
+    assert.match(JSON.stringify(stderrRead.output), /ERR_SENTINEL/);
+    assert.match(JSON.stringify(stderrRead.output), /"exitCode":7/);
+    assert.equal(executeCalls, 1);
+    const archived = [...store.values()][0] ?? '';
+    assert.match(archived, /TAIL_SENTINEL/);
   });
 
   test('accumulates pruning across provider steps once in persisted and live usage', async () => {
diff --git a/packages/runtime/src/__tests__/bash-tail-buffer.test.ts b/packages/runtime/src/__tests__/bash-tail-buffer.test.ts
index 739faf41b3..40c44fd0c5 100644
--- a/packages/runtime/src/__tests__/bash-tail-buffer.test.ts
+++ b/packages/runtime/src/__tests__/bash-tail-buffer.test.ts
@@ -22,54 +22,37 @@ import { describe, test } from 'node:test';
 import { BashTailBuffer } from '../bash-tail-buffer.js';
 
 describe('BashTailBuffer', () => {
-  test('bounds retained output to the cap and keeps whole tail lines', () => {
+  test('bounds retained output to the exact tail', () => {
     const buf = new BashTailBuffer(20);
     for (let i = 0; i < 100; i++) buf.push(`line${i}\n`);
     const value = buf.value();
     assert.ok(value.length <= 20);
     assert.ok(value.endsWith('line99\n')); // tail preserved
-    assert.ok(value.startsWith('line')); // starts at a line boundary, not mid-line
+    assert.equal(value, 'ine97\nline98\nline99\n');
   });
 
-  test('drops the partial leading line so a sliced secret prefix cannot survive', () => {
-    // The first line would be cut mid-secret; the buffer must drop it whole so a
-    // later redaction pass never sees a secret with its prefix sliced off.
-    const buf = new BashTailBuffer(10);
-    buf.push('SECRETXYZ\n');
-    buf.push('KEEP1\nKEEP2\n');
-    const value = buf.value();
-    assert.equal(value, 'KEEP2\n');
-    assert.ok(!value.includes('SECRET'));
-  });
-
-  test('drops a single oversized line with no newline (no safe redaction boundary)', () => {
-    // A single line larger than the cap has no line boundary to cut on; keeping
-    // a byte-slice could hand redaction a secret with its prefix sliced off, so
-    // it is dropped entirely. Pathological case — the common single line is
-    // under the cap and never sliced.
+  test('keeps the tail of an oversized line', () => {
     const buf = new BashTailBuffer(5);
-    buf.push('Authorization: Bearer sk-live-secret-token-value'); // one giant line
-    assert.equal(buf.value(), '');
-    // It also flags the unsafe drop so callers can mark the empty result.
-    assert.equal(buf.hasDroppedUnsafe(), true);
+    buf.push('abcdefghij');
+    assert.equal(buf.value(), 'fghij');
   });
 
-  test('does not flag a safe drop (partial leading line trimmed at a newline)', () => {
-    const buf = new BashTailBuffer(8);
-    buf.push('aaaa\nbbbb\ncccc\n'); // sliced at a newline boundary — safe, not unsafe
-    assert.ok(buf.value().length <= 8);
-    assert.equal(buf.hasDroppedUnsafe(), false);
+  test('retains the same tail across chunk boundaries', () => {
+    const buf = new BashTailBuffer(6);
+    buf.push('abc');
+    buf.push('def');
+    buf.push('ghi');
+    assert.equal(buf.value(), 'defghi');
   });
 
-  test('keeps discarding continuation chunks of a dropped oversized line until a newline', () => {
-    // The oversized line arrives across multiple chunks (stdout does not split on
-    // line boundaries). After the prefix is dropped, the suffix chunk must stay
-    // discarded — otherwise redaction would later see a secret with no prefix.
-    const buf = new BashTailBuffer(20);
-    buf.push('Authorization: Bearer sk-live-' + 'A'.repeat(30)); // oversized prefix dropped
-    buf.push('secret_tail'); // continuation of the SAME line
-    assert.equal(buf.value(), '');
-    buf.push(' more\nKEEP\n'); // newline terminates the compromised line; resume after it
-    assert.equal(buf.value(), 'KEEP\n');
+  test('does not split a surrogate pair at the truncation boundary', () => {
+    for (const [cap, expected] of [
+      [3, '😀x'],
+      [2, 'x'],
+    ] as const) {
+      const buf = new BashTailBuffer(cap);
+      buf.push('abc😀x');
+      assert.equal(buf.value(), expected);
+    }
   });
 });
diff --git a/packages/runtime/src/__tests__/builtin-tools.test.ts b/packages/runtime/src/__tests__/builtin-tools.test.ts
index 71a583da86..395ad350d9 100644
--- a/packages/runtime/src/__tests__/builtin-tools.test.ts
+++ b/packages/runtime/src/__tests__/builtin-tools.test.ts
@@ -65,6 +65,7 @@ import {
   type WorkspaceExecutorFacts,
 } from '../workspace-executor.js';
 import { waitFor as pollFor } from '@maka/core/test-only/async-primitives';
+import { BASH_MAX_RETAINED_CHARS } from '../shell-exec.js';
 
 const ONE_PIXEL_PNG = Buffer.from(
   'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==',
@@ -1913,31 +1914,10 @@ describe('builtin Bash streaming output', () => {
     if (!bash) throw new Error('Bash tool missing');
 
     const result = (await bash.impl(
-      { command: 'awk \'BEGIN{for(i=1;i<=5000;i++)print "line"i}\'', timeout_ms: 10_000 },
       {
-        sessionId: 'session-1',
-        turnId: 'turn-1',
-        cwd,
-        toolCallId: 'tool-1',
-        abortSignal: new AbortController().signal,
-        emitOutput: () => {},
+        command: 'perl -e \'print "HEAD\\n", "x" x 2000000, "\\nTAIL\\n"\'',
+        timeout_ms: 10_000,
       },
-    )) as { exitCode: number; output: { stdout: string; stdoutTruncated: boolean } };
-
-    assert.strictEqual(result.exitCode, 0); // no reject — the old code threw away everything past the cap
-    assert.strictEqual(result.output.stdout.includes('line5000'), true); // tail preserved
-    assert.strictEqual(result.output.stdout.includes('truncated'), true); // truncation marker present
-    assert.strictEqual(result.output.stdout.includes('line1\n'), false); // head dropped, not the whole output
-    assert.strictEqual(result.output.stdoutTruncated, true);
-  });
-
-  test('foreground Bash marks retained-tail truncation even when model shaping does not truncate again', async () => {
-    const cwd = await mkdtemp(join(tmpdir(), 'maka-bash-'));
-    const bash = buildBuiltinTools().find((tool) => tool.name === 'Bash');
-    if (!bash) throw new Error('Bash tool missing');
-
-    const result = (await bash.impl(
-      { command: 'perl -e \'print "x" x 2000000\'', timeout_ms: 10_000 },
       {
         sessionId: 'session-1',
         turnId: 'turn-1',
@@ -1946,10 +1926,13 @@ describe('builtin Bash streaming output', () => {
         abortSignal: new AbortController().signal,
         emitOutput: () => {},
       },
-    )) as { output: { stdout: string; stdoutTruncated: boolean } };
+    )) as { exitCode: number; output: { stdout: string; stdoutTruncated: boolean } };
 
+    assert.strictEqual(result.exitCode, 0);
+    assert.strictEqual(result.output.stdout.endsWith('\nTAIL\n'), true);
+    assert.strictEqual(result.output.stdout.includes('HEAD\n'), false);
+    assert.ok(result.output.stdout.length <= BASH_MAX_RETAINED_CHARS);
     assert.strictEqual(result.output.stdoutTruncated, true);
-    assert.ok(result.output.stdout.includes('omitted for safety'));
   });
 
   test('a failing command surfaces stdout/stderr on the rejection error', async () => {
diff --git a/packages/runtime/src/__tests__/durable-tool-result-projection.test.ts b/packages/runtime/src/__tests__/durable-tool-result-projection.test.ts
index a1094c8e1e..4b09fa8849 100644
--- a/packages/runtime/src/__tests__/durable-tool-result-projection.test.ts
+++ b/packages/runtime/src/__tests__/durable-tool-result-projection.test.ts
@@ -96,7 +96,7 @@ describe('durable Tool Result projection codec', () => {
       'session-1',
     );
     const oversized = encodeDurableToolResultOutput(
-      { type: 'json', value: { body: 'x'.repeat(300_000) } },
+      { type: 'json', value: { body: 'x'.repeat(DURABLE_TOOL_RESULT_PROJECTION_MAX_BYTES) } },
       'session-1',
     );
 
diff --git a/packages/runtime/src/__tests__/shell-exec.test.ts b/packages/runtime/src/__tests__/shell-exec.test.ts
index 18c1867772..82e055cfdc 100644
--- a/packages/runtime/src/__tests__/shell-exec.test.ts
+++ b/packages/runtime/src/__tests__/shell-exec.test.ts
@@ -82,7 +82,7 @@ describe('runShellWithBoundedTail', () => {
     assert.equal(result.stdout, 'legacy-stdin-ready\n');
   });
 
-  test('keeps only the bounded, line-aligned TAIL of large output (never killed by size)', async () => {
+  test('keeps only the bounded tail of large output (never killed by size)', async () => {
     const r = await runShellWithBoundedTail(
       "printf 'HEADMARK\\n'; seq 1 50; printf 'TAILMARK\\n'",
       base({ maxRetainedChars: 12 }),
@@ -240,21 +240,14 @@ describe('runShellWithBoundedTail', () => {
     }
   });
 
-  test('surfaces a safety marker (not bare empty) when an oversized no-newline line is dropped', async () => {
-    // One 500-char line with no newline, cap 50: BashTailBuffer drops it whole
-    // (no safe truncation boundary), so without a marker the result would look
-    // like the command produced nothing.
+  test('keeps the bounded tail of oversized output without a newline', async () => {
     const r = await runShellWithBoundedTail(
       "head -c 500 /dev/zero | tr '\\0' x",
       base({ maxRetainedChars: 50 }),
     );
     assert.equal(r.exitCode, 0);
-    assert.ok(!r.stdout.includes('xxxx'), 'dropped content is not leaked');
-    assert.ok(r.stdout.includes('omitted for safety'), 'a recoverable safety marker is present');
+    assert.equal(r.stdout, 'x'.repeat(50));
     assert.equal(r.stdoutTruncated, true);
-    // Shares the recovery hint with truncateToolOutput: re-run only when safe.
-    assert.ok(r.stdout.includes('safe to re-run'), 'recovery guidance is conditioned on safety');
-    assert.ok(r.stdout.includes('side effects'), 'warns about repeating side effects');
   });
 
   test('spawns a detected PowerShell explicitly with non-interactive flags (not via shell:true)', async () => {
diff --git a/packages/runtime/src/__tests__/shell-run-manager.test.ts b/packages/runtime/src/__tests__/shell-run-manager.test.ts
index a462b1363e..212ea60474 100644
--- a/packages/runtime/src/__tests__/shell-run-manager.test.ts
+++ b/packages/runtime/src/__tests__/shell-run-manager.test.ts
@@ -145,14 +145,14 @@ describe('ShellRunProcessManager', () => {
     assert.deepEqual(completions, [false]);
   });
 
-  test('keeps the default pipe path separated, durable, redacted, and observed', async () => {
+  test('keeps the default pipe path raw, separated, durable, and observed', async () => {
     const cwd = await workspace();
     const store = sqliteShellRunStore(cwd);
     const manager = createManager(store);
     const result = await manager.runForegroundBash(
       shellInput({
         cwd,
-        command: 'printf "hello"; printf "warning" >&2',
+        command: 'printf "Authorization: Bearer sk-live-secret-token-value"; printf "warning" >&2',
       }),
     );
 
@@ -161,11 +161,16 @@ describe('ShellRunProcessManager', () => {
     assert.equal(result.exitCode, 0);
     assert.equal(result.output.mode, 'pipes');
     if (result.output.mode !== 'pipes') throw new Error('expected pipes output');
-    assert.equal(result.output.stdout, 'hello');
+    assert.equal(result.output.stdout, 'Authorization: Bearer sk-live-secret-token-value');
     assert.equal(result.output.stderr, 'warning');
     assert.equal(result.output.latestStream, 'stderr');
+    assert.equal(result.output.redacted, false);
 
     const record = await store.readShellRun('session-1', 'shell-run-1');
+    assert.equal(
+      record.command,
+      'printf "Authorization: Bearer sk-live-secret-token-value"; printf "warning" >&2',
+    );
     assert.equal(record.output.mode, 'pipes');
     assert.equal(record.status, 'completed');
     assert.ok(record.revision >= 2);
diff --git a/packages/runtime/src/__tests__/shell-run-tool-result.test.ts b/packages/runtime/src/__tests__/shell-run-tool-result.test.ts
index ff6cfe7041..317c8e39ad 100644
--- a/packages/runtime/src/__tests__/shell-run-tool-result.test.ts
+++ b/packages/runtime/src/__tests__/shell-run-tool-result.test.ts
@@ -33,7 +33,20 @@ import {
   terminalContent,
 } from '../shell-run-tool-result.js';
 
-describe('PTY model output projection', () => {
+describe('shell result projection', () => {
+  test('foreground terminal results retain executor output for durable Read recovery', () => {
+    const record = failedShellRun();
+    record.output = {
+      mode: 'pipes',
+      stdout: `FRONT\n${'x'.repeat(100_000)}\nTAIL`,
+      stderr: 'ERROR',
+      stdoutTruncated: true,
+      stderrTruncated: false,
+      redacted: false,
+    };
+    assert.deepEqual(terminalContent(record).output, record.output);
+  });
+
   test('shares one UTF-8 budget in screen, alternate, then latest scrollback priority', () => {
     const output = ptyOutput({
       screen: 'SCREEN',
diff --git a/packages/runtime/src/__tests__/shell-tools.test.ts b/packages/runtime/src/__tests__/shell-tools.test.ts
index c128d71060..c63c0bae8e 100644
--- a/packages/runtime/src/__tests__/shell-tools.test.ts
+++ b/packages/runtime/src/__tests__/shell-tools.test.ts
@@ -229,7 +229,24 @@ describe('Bash provider-facing result projection', () => {
   });
 });
 
-describe('shapeTerminalResult sandbox denial projection', () => {
+describe('shapeTerminalResult projection', () => {
+  test('preserves foreground command output verbatim', () => {
+    const command = 'curl -H "Authorization: Bearer token-value" example.test';
+    const stdout = 'Authorization: Bearer token-value';
+    const stderr = 'api_key=token-value';
+    const result = shapeTerminalResult({
+      cwd: '/ws',
+      command,
+      result: { exitCode: 7, stdout, stderr },
+    });
+
+    assert.equal(result.cmd, command);
+    assert.ok(result.output.mode === 'pipes');
+    assert.equal(result.output.stdout, stdout);
+    assert.equal(result.output.stderr, stderr);
+    assert.equal(result.output.redacted, false);
+  });
+
   test('surfaces sandboxDenial when a sandboxed command fails with a denial message', () => {
     const result = shapeTerminalResult({
       cwd: '/ws',
diff --git a/packages/runtime/src/bash-tail-buffer.ts b/packages/runtime/src/bash-tail-buffer.ts
index bfe5b98522..244958a1bf 100644
--- a/packages/runtime/src/bash-tail-buffer.ts
+++ b/packages/runtime/src/bash-tail-buffer.ts
@@ -22,46 +22,16 @@
 // Memory-bounded tail accumulator for streaming shell output. A runaway command
 // must not be able to grow the captured result without limit (the old Bash path
 // instead discarded ALL output past a hard cap), so we retain only the last
-// `cap` characters.
-//
-// SECURITY: the retained tail is later passed through redactSecrets before it
-// reaches the model / UI. redactSecrets matches on full tokens and their
-// prefixes (e.g. `Authorization: Bearer …`, `sk-…`), so a tail that begins in
-// the MIDDLE of a secret-bearing line would defeat redaction and leak the
-// suffix. To prevent that, when the buffer actually drops content it also drops
-// the (partial) leading line, so the retained tail always starts at a line
-// boundary and redaction sees whole lines.
+// `cap` characters. Trimming avoids splitting a UTF-16 surrogate pair.
 
 export class BashTailBuffer {
   private chunks: string[] = [];
   private retained = 0;
-  // True when we dropped an unterminated oversized line: subsequent chunks are
-  // still part of that compromised line and must be discarded until the next
-  // newline, otherwise a continuation chunk (a secret's severed suffix) would
-  // be retained as if it were a clean line and defeat downstream redaction.
-  private insideDroppedLine = false;
-  // Latches true once we drop an oversized no-newline line entirely (the case
-  // above). Callers surface a marker so a result that looks empty is not
-  // mistaken for "the command produced nothing" — content existed but could not
-  // be safely truncated.
-  private droppedUnsafe = false;
 
   constructor(private readonly cap: number) {}
 
-  /** Whether an oversized, unsafe-to-truncate line was dropped from the tail. */
-  hasDroppedUnsafe(): boolean {
-    return this.droppedUnsafe;
-  }
-
   push(chunk: string): void {
     if (!chunk) return;
-    if (this.insideDroppedLine) {
-      const nl = chunk.indexOf('\n');
-      if (nl < 0) return; // whole chunk is still the dropped line — discard it
-      this.insideDroppedLine = false;
-      chunk = chunk.slice(nl + 1); // resume from the first clean line boundary
-      if (!chunk) return;
-    }
     this.chunks.push(chunk);
     this.retained += chunk.length;
     // Amortize: allow growth to 2x cap before compacting back to cap so appends
@@ -77,27 +47,24 @@ export class BashTailBuffer {
   private trim(): void {
     if (this.chunks.length <= 1 && this.retained <= this.cap) return;
     const joined = this.chunks.join('');
-    let kept = joined.length > this.cap ? joined.slice(joined.length - this.cap) : joined;
-    if (kept.length < joined.length) {
-      // We sliced mid-stream. Drop the partial leading line so the retained tail
-      // starts at a line boundary and downstream redaction never sees a secret
-      // whose prefix was cut off (see SECURITY note above). With no newline the
-      // whole tail is one partial line we cannot make safe — drop it entirely
-      // rather than risk leaking a severed secret. (A single line larger than
-      // the cap is pathological; the common single-line case is <= cap and is
-      // never sliced here.)
-      const nl = kept.indexOf('\n');
-      if (nl >= 0) {
-        kept = kept.slice(nl + 1);
-      } else {
-        // No line boundary in the retained tail: drop it and stay "inside" the
-        // dropped line so its continuation chunks are discarded until a newline.
-        kept = '';
-        this.insideDroppedLine = true;
-        this.droppedUnsafe = true;
-      }
+    let start = Math.max(0, joined.length - this.cap);
+    if (
+      start > 0 &&
+      isLowSurrogate(joined.charCodeAt(start)) &&
+      isHighSurrogate(joined.charCodeAt(start - 1))
+    ) {
+      start += 1;
     }
+    const kept = joined.slice(start);
     this.chunks = kept ? [kept] : [];
     this.retained = kept.length;
   }
 }
+
+function isHighSurrogate(code: number): boolean {
+  return code >= 0xd800 && code <= 0xdbff;
+}
+
+function isLowSurrogate(code: number): boolean {
+  return code >= 0xdc00 && code <= 0xdfff;
+}
diff --git a/packages/runtime/src/pipe-tail-collector.ts b/packages/runtime/src/pipe-tail-collector.ts
index 91bcfa334a..9feef583da 100644
--- a/packages/runtime/src/pipe-tail-collector.ts
+++ b/packages/runtime/src/pipe-tail-collector.ts
@@ -18,10 +18,9 @@
  */
 
 import type { PipeShellOutput } from '@maka/core/shell-run';
-import { redactSecrets } from '@maka/core/redaction';
 
 import { BashTailBuffer } from './bash-tail-buffer.js';
-import { BASH_MAX_RETAINED_CHARS, shellTailValueWithUnsafeDropMarker } from './shell-exec.js';
+import { BASH_MAX_RETAINED_CHARS } from './shell-exec.js';
 
 export class PipeTailCollector {
   private readonly stdout: BashTailBuffer;
@@ -51,18 +50,16 @@ export class PipeTailCollector {
   }
 
   snapshot(): PipeShellOutput {
-    const stdoutRaw = shellTailValueWithUnsafeDropMarker(this.stdout);
-    const stderrRaw = shellTailValueWithUnsafeDropMarker(this.stderr);
-    const stdout = redactSecrets(stdoutRaw);
-    const stderr = redactSecrets(stderrRaw);
+    const stdout = this.stdout.value();
+    const stderr = this.stderr.value();
     return {
       mode: 'pipes',
       stdout,
       stderr,
       ...(this.latestStream ? { latestStream: this.latestStream } : {}),
-      stdoutTruncated: this.stdoutChars > stdoutRaw.length || this.stdout.hasDroppedUnsafe(),
-      stderrTruncated: this.stderrChars > stderrRaw.length || this.stderr.hasDroppedUnsafe(),
-      redacted: stdout !== stdoutRaw || stderr !== stderrRaw,
+      stdoutTruncated: this.stdoutChars > stdout.length,
+      stderrTruncated: this.stderrChars > stderr.length,
+      redacted: false,
     };
   }
 
diff --git a/packages/runtime/src/shell-exec.ts b/packages/runtime/src/shell-exec.ts
index d277aee75d..ea2fce3982 100644
--- a/packages/runtime/src/shell-exec.ts
+++ b/packages/runtime/src/shell-exec.ts
@@ -36,7 +36,6 @@ import {
   manageChildProcessLifecycle,
   type ChildProcessLifecycleResult,
 } from './child-process-lifecycle.js';
-import { OUTPUT_RECOVERY_HINT } from './tool-output.js';
 import {
   buildSpawnStdio,
   closeChildFdSources,
@@ -45,8 +44,9 @@ import {
 } from './child-fd-input.js';
 
 // Per-stream cap on the output RETAINED for the result (~1MB). This only bounds
-// what is kept to return. The tool layer (truncateToolOutput) trims this further
-// to the model's budget. Shared so both Bash paths retain identically.
+// what is kept to return. The tool layer preserves this result for durable
+// storage; unified result pruning bounds its model projection.
+// Shared so both Bash paths retain identically.
 export const BASH_MAX_RETAINED_CHARS = 1024 * 1024;
 
 // Per-stream cap on output forwarded LIVE via emitOutput (~1MB). The command is
@@ -63,22 +63,6 @@ export const LIVE_OUTPUT_SUPPRESSED_MARKER =
   '[live output suppressed: too much output to stream live; the command keeps ' +
   'running and its result still contains the most recent output]';
 
-// Appended to a stream when BashTailBuffer dropped an oversized line that had no
-// newline to truncate at (dropped whole for redaction safety). Without it, a
-// command whose only output was one giant line would look like it produced
-// nothing. Carries no dropped content — just a recoverable notice.
-const UNSAFE_DROP_MARKER =
-  '[a single line larger than the output limit was omitted for safety. ' +
-  OUTPUT_RECOVERY_HINT +
-  ']';
-
-export function shellTailValueWithUnsafeDropMarker(buf: BashTailBuffer): string {
-  const text = buf.value(); // value() trims first, so the drop flag is current after it
-  if (!buf.hasDroppedUnsafe()) return text;
-  // Append (not prepend) so a later tail-keeping truncateToolOutput retains it.
-  return text ? `${text}\n${UNSAFE_DROP_MARKER}` : UNSAFE_DROP_MARKER;
-}
-
 export interface BoundedShellOptions {
   cwd: string;
   /** Hard wall-clock cap; the child is SIGTERM'd and `timedOut` is set. */
@@ -105,7 +89,7 @@ export interface BoundedShellOptions {
 
 export interface BoundedShellResult {
   exitCode: number;
-  /** Last `maxRetainedChars` of stdout (line-aligned; see BashTailBuffer). */
+  /** Last `maxRetainedChars` of stdout. */
   stdout: string;
   /** Last `maxRetainedChars` of stderr. */
   stderr: string;
@@ -296,8 +280,8 @@ function runSpawnedProcessWithBoundedTail(
       if (settled) return;
       settled = true;
       cleanup();
-      const stdout = shellTailValueWithUnsafeDropMarker(stdoutBuf);
-      const stderr = shellTailValueWithUnsafeDropMarker(stderrBuf);
+      const stdout = stdoutBuf.value();
+      const stderr = stderrBuf.value();
       resolvePromise({
         exitCode: termination
           ? termination.timedOut
@@ -306,14 +290,8 @@ function runSpawnedProcessWithBoundedTail(
           : (outcome.exitCode ?? (outcome.signal ? 128 : 1)),
         stdout,
         stderr,
-        stdoutTruncated:
-          outcome.incompleteOutputs.has('stdout') ||
-          stdoutChars > stdout.length ||
-          stdoutBuf.hasDroppedUnsafe(),
-        stderrTruncated:
-          outcome.incompleteOutputs.has('stderr') ||
-          stderrChars > stderr.length ||
-          stderrBuf.hasDroppedUnsafe(),
+        stdoutTruncated: outcome.incompleteOutputs.has('stdout') || stdoutChars > stdout.length,
+        stderrTruncated: outcome.incompleteOutputs.has('stderr') || stderrChars > stderr.length,
         timedOut: !!termination?.timedOut,
         aborted: !!termination?.aborted,
       });
diff --git a/packages/runtime/src/shell-run-manager.ts b/packages/runtime/src/shell-run-manager.ts
index c044e1faa1..f9ad47bd31 100644
--- a/packages/runtime/src/shell-run-manager.ts
+++ b/packages/runtime/src/shell-run-manager.ts
@@ -950,7 +950,7 @@ export class ShellRunProcessManager
       sourceToolCallId: input.sourceToolCallId,
       ...(input.visibility === undefined ? {} : { visibility: input.visibility }),
       cwd: input.cwd,
-      command: redactSecrets(input.command),
+      command: input.command,
       status: 'starting',
       startedAt,
       updatedAt: startedAt,
diff --git a/packages/runtime/src/shell-run-tool-result.ts b/packages/runtime/src/shell-run-tool-result.ts
index 0601b88143..c7def411c2 100644
--- a/packages/runtime/src/shell-run-tool-result.ts
+++ b/packages/runtime/src/shell-run-tool-result.ts
@@ -67,7 +67,7 @@ export function terminalContent(record: ShellRunRecord): TerminalToolResult {
     status: terminalResultStatus(record.status),
     ...(record.exitCode !== undefined ? { exitCode: record.exitCode } : {}),
     ...(record.failureMessage !== undefined ? { failureMessage: record.failureMessage } : {}),
-    output: projectShellOutputForModel(record.output),
+    output: record.output,
     ...(sandboxDenialForRecord(record) ? { sandboxDenial: sandboxDenialForRecord(record) } : {}),
   };
 }
diff --git a/packages/runtime/src/shell-tools.ts b/packages/runtime/src/shell-tools.ts
index 772f2e1cb6..7be0ee63df 100644
--- a/packages/runtime/src/shell-tools.ts
+++ b/packages/runtime/src/shell-tools.ts
@@ -31,7 +31,6 @@ import {
   type TerminalInputAction,
 } from '@maka/core/terminal-input';
 import { isActiveShellRunStatus } from '@maka/core/shell-run';
-import { redactSecrets } from '@maka/core/redaction';
 import type { ToolResultContent } from '@maka/core/events';
 import type { ToolExecutionFacts } from '@maka/core/permission';
 import type { SandboxBoundaryExpansion } from '@maka/core/sandbox-boundary';
@@ -47,7 +46,6 @@ import {
   throwIfShellSetupFailed,
   type TurnShellPlan,
 } from './shell-detect.js';
-import { truncateToolOutput } from './tool-output.js';
 import {
   DEFAULT_BASH_TIMEOUT_MS,
   MAX_PTY_COLS,
@@ -694,23 +692,19 @@ export function shapeTerminalResult(input: {
   command: string;
   result: ForegroundBashResult | BoundedShellResult;
 }): TerminalToolResult {
-  const stdout = redactSecrets(input.result.stdout);
-  const stderr = redactSecrets(input.result.stderr);
-  const stdoutView = truncateToolOutput(stdout, { direction: 'tail' });
-  const stderrView = truncateToolOutput(stderr, { direction: 'tail' });
   return {
     kind: 'terminal',
     cwd: input.cwd,
-    cmd: redactSecrets(input.command),
+    cmd: input.command,
     status: terminalStatus(input.result),
     exitCode: input.result.exitCode,
     output: {
       mode: 'pipes',
-      stdout: stdoutView.content,
-      stderr: stderrView.content,
-      stdoutTruncated: Boolean(input.result.stdoutTruncated) || stdoutView.truncated,
-      stderrTruncated: Boolean(input.result.stderrTruncated) || stderrView.truncated,
-      redacted: stdout !== input.result.stdout || stderr !== input.result.stderr,
+      stdout: input.result.stdout,
+      stderr: input.result.stderr,
+      stdoutTruncated: Boolean(input.result.stdoutTruncated),
+      stderrTruncated: Boolean(input.result.stderrTruncated),
+      redacted: false,
     },
     ...(isLikelySandboxDenial({
       stdout: input.result.stdout,
diff --git a/packages/runtime/src/tool-result-archive-resource.ts b/packages/runtime/src/tool-result-archive-resource.ts
index 2b400c17de..8240ee8536 100644
--- a/packages/runtime/src/tool-result-archive-resource.ts
+++ b/packages/runtime/src/tool-result-archive-resource.ts
@@ -18,11 +18,12 @@
  */
 
 import type { ToolResultArchiveReadResult } from './tool-result-archive.js';
+import { TOOL_RESULT_ARCHIVE_EVIDENCE_MAX_BYTES } from '@maka/core/tool-result-archive-evidence';
 import { readToolResultPage, resolveReadInput, type ReadInput } from './read-page.js';
 
 export const TOOL_RESULT_ARCHIVE_RESOURCE_PROTOCOL = 'maka:';
 export const TOOL_RESULT_ARCHIVE_RESOURCE_HOST = 'archive';
-export const TOOL_RESULT_ARCHIVE_MAX_BYTES = 4 * 1024 * 1024;
+export const TOOL_RESULT_ARCHIVE_MAX_BYTES = TOOL_RESULT_ARCHIVE_EVIDENCE_MAX_BYTES;
 const ARCHIVE_ARTIFACT_ID_PATTERN = /^[A-Za-z0-9._-]{1,160}$/;
 
 export interface LegacyArchiveResourceIdentity {
diff --git a/packages/storage/src/__tests__/tool-result-archive-evidence.test.ts b/packages/storage/src/__tests__/tool-result-archive-evidence.test.ts
index 600a38e204..1c2b5f92e9 100644
--- a/packages/storage/src/__tests__/tool-result-archive-evidence.test.ts
+++ b/packages/storage/src/__tests__/tool-result-archive-evidence.test.ts
@@ -23,6 +23,7 @@ import { tmpdir } from 'node:os';
 import { join } from 'node:path';
 import { DatabaseSync } from 'node:sqlite';
 import { test, after, type TestContext } from 'node:test';
+import { TOOL_RESULT_ARCHIVE_EVIDENCE_MAX_BYTES } from '@maka/core/tool-result-archive-evidence';
 import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '../root-authority.js';
 import { openToolResultArchiveEvidenceReader } from '../tool-result-archive-evidence.js';
 import { MODEL_PROJECTION_TARGET_SQL } from '../sqlite-core-execution-schema.js';
@@ -138,7 +139,7 @@ test('target evidence ignores 12k unrelated records, survives reopen and checks
 
 test('checks byte and record budgets before fetching any ledger JSON', async (t) => {
   const f = await fixture(t);
-  f.insert(1, 'response', 'x'.repeat(3 * 1024 * 1024));
+  f.insert(1, 'response', 'x'.repeat(TOOL_RESULT_ARCHIVE_EVIDENCE_MAX_BYTES));
   let materialized = 0;
   const prepare = DatabaseSync.prototype.prepare;
   t.mock.method(DatabaseSync.prototype, 'prepare', function (this: DatabaseSync, sql: string) {
@@ -178,7 +179,7 @@ test('checks byte and record budgets before fetching any ledger JSON', async (t)
     .prepare(
       "UPDATE runtime_events SET payload_json = json_set(payload_json, '$.content.modelProjection.text', ?) WHERE event_id = 'response'",
     )
-    .run('x'.repeat(3 * 1024 * 1024));
+    .run('x'.repeat(TOOL_RESULT_ARCHIVE_EVIDENCE_MAX_BYTES));
   assert.deepEqual(await f.reader.read({ sessionId: 'session', runtimeEventId: 'response' }), {
     ok: false,
     reason: 'too_large',


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


(maka) 01/02: fix(runtime): page terminal failure details with output

Posted by as...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

Astro-Han pushed a commit to branch refactor/tool-results-unified-read
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 15fa70eb32f89a536cb14dfa9d869bf927ec00ad
Author: AstroHan <le...@outlook.com>
AuthorDate: Sun Sep 13 16:49:07 2026 +0800

    fix(runtime): page terminal failure details with output
---
 packages/runtime/src/__tests__/read-page.test.ts | 44 ++++++++++++++++++++++--
 packages/runtime/src/read-page.ts                | 26 +++++++-------
 2 files changed, 56 insertions(+), 14 deletions(-)

diff --git a/packages/runtime/src/__tests__/read-page.test.ts b/packages/runtime/src/__tests__/read-page.test.ts
index f1787984b1..3137fb48a3 100644
--- a/packages/runtime/src/__tests__/read-page.test.ts
+++ b/packages/runtime/src/__tests__/read-page.test.ts
@@ -105,9 +105,9 @@ test('archive Read decodes content lines and retains terminal execution metadata
     }),
     { path: 'maka://runtime/tool-results/e' },
   );
-  assert.equal(page.content, 'one\ntwo\nerror');
+  assert.equal(page.content, 'one\ntwo\nerror\ncommand failed');
   assert.equal(page.metadata?.exitCode, 2);
-  assert.equal(page.metadata?.failureMessage, 'command failed');
+  assert.equal(page.metadata?.failureMessage, undefined);
   assert.equal(page.next, null);
   assert.deepEqual(readPage('', { path: 'empty', offset: 3 }), {
     content: '',
@@ -118,6 +118,46 @@ test('archive Read decodes content lines and retains terminal execution metadata
   });
 });
 
+test('archive Read pages a long shell failure without losing output or execution metadata', () => {
+  const stdout = 'one\ntwo';
+  const stderr = 'error';
+  const failureMessage = `failure: ${'界\\"'.repeat(4_000)}`;
+  const serialized = JSON.stringify({
+    kind: 'terminal',
+    cwd: '/workspace',
+    cmd: 'failing-command',
+    status: 'failed',
+    exitCode: 7,
+    failureMessage,
+    output: {
+      mode: 'pipes',
+      stdout,
+      stderr,
+      stdoutTruncated: false,
+      stderrTruncated: false,
+      redacted: false,
+    },
+  });
+  let input: ReadInput | null = { path: 'maka://runtime/tool-results/failure' };
+  let recovered = '';
+  let pages = 0;
+  while (input) {
+    const page = readToolResultPage(serialized, input);
+    assert.ok(JSON.stringify(page).length <= READ_PAGE_MAX_CHARS);
+    assert.equal(page.metadata?.status, 'failed');
+    assert.equal(page.metadata?.exitCode, 7);
+    assert.equal(page.metadata?.failureMessage, undefined);
+    recovered += page.content;
+    if (page.next && page.partialLine !== true) recovered += '\n';
+    input = page.next;
+    pages += 1;
+    assert.ok(pages < 100);
+  }
+
+  assert.ok(pages > 1);
+  assert.equal(recovered, `${stdout}\n${stderr}\n${failureMessage}`);
+});
+
 test('archive Read preserves fields beside structured text across pages', () => {
   for (const value of [
     { content: 'x'.repeat(9000), cursor: 'CURSOR-123', status: 'more' },
diff --git a/packages/runtime/src/read-page.ts b/packages/runtime/src/read-page.ts
index 647d535115..955fc59d3d 100644
--- a/packages/runtime/src/read-page.ts
+++ b/packages/runtime/src/read-page.ts
@@ -217,20 +217,24 @@ export function readableToolResult(serialized: string): string {
       value &&
       typeof value === 'object' &&
       'kind' in value &&
-      (value.kind === 'terminal' || value.kind === 'shell_run') &&
-      'output' in value
+      (value.kind === 'terminal' || value.kind === 'shell_run')
     ) {
-      if (typeof value.output === 'string') return value.output;
-      if (value.output && typeof value.output === 'object') {
-        const output = value.output as Record<string, unknown>;
-        return (
-          output.mode === 'pty'
-            ? [output.scrollback, output.screen, output.lastAlternateScreen]
-            : [output.stdout, output.stderr]
+      const shell = value as Record<string, unknown>;
+      let output: string | undefined;
+      if (typeof shell.output === 'string') output = shell.output;
+      if (shell.output && typeof shell.output === 'object') {
+        const shellOutput = shell.output as Record<string, unknown>;
+        output = (
+          shellOutput.mode === 'pty'
+            ? [shellOutput.scrollback, shellOutput.screen, shellOutput.lastAlternateScreen]
+            : [shellOutput.stdout, shellOutput.stderr]
         )
           .filter((part): part is string => typeof part === 'string' && part.length > 0)
           .join('\n');
       }
+      if (typeof shell.failureMessage === 'string' && shell.failureMessage.length > 0)
+        return output ? `${output}\n${shell.failureMessage}` : shell.failureMessage;
+      if (output !== undefined) return output;
     }
   } catch {
     /* Plain text projections are already readable. */
@@ -249,9 +253,7 @@ export function readToolResultPage(
     if (value?.kind === 'terminal' || value?.kind === 'shell_run') {
       metadata = Object.fromEntries(
         Object.entries(value).filter(([key]) =>
-          ['kind', 'status', 'exitCode', 'signal', 'revision', 'mode', 'failureMessage'].includes(
-            key,
-          ),
+          ['kind', 'status', 'exitCode', 'signal', 'revision', 'mode'].includes(key),
         ),
       );
       if (value.output && typeof value.output === 'object')


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