anomalyco / anomalyco/opencode

shell tool: truncation spill file is incomplete (tail lost, torn lines) — stream consumer interrupted at process exit

Open
#40,728 0 comments 0 reactions 1 assignee View on GitHub

@jlongster is already working on this.

Since Aug 5, 2026.

Dominant language
TypeScript
Stars
209k
Forks
27.5k
PR merge metrics
PR metrics pending

Description

Summary

When a shell command's output exceeds the tool_output caps, the truncation notice tells the model:

The tool call succeeded but the output was truncated. Full output saved to: <path>

That saved file is not the full output for large, fast-producing commands. Both the spill file and the agent-visible tail preview silently lose the tail of the output (and the spill file can end mid-line, or even lose its head chunk). The whole recovery story for truncated output — "go read the saved file" — assumes the saved file is complete, so this quietly breaks agents that follow the hint. We observed a production agent read output ending mid-report and fabricate the missing rows.

Reproduced on opencode-ai@1.17.9 and on current dev (b1f8cc0, 1.18.x).

Repro

seq 1 50000 through the shell tool (default caps: 2000 lines / 50 KiB). Observed spill files across runs:

  • lines 1–37348 followed by a torn partial line 3734 (of 50,000)
  • a file starting at line 1861 — the first 8,192 bytes (exactly one 8 KiB chunk) missing — and ending at 38,715

The agent-visible tail preview ends at the same point, so the model sees output ending around line ~37,350 with no indication that ~12,650 more lines ever existed.

Deterministic test (drop into packages/opencode/test/tool/spill-repro.test.ts; fails ~5/5 on dev on my box):

import { describe, expect } from "bun:test"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Effect, Layer } from "effect"
import path from "path"
import { Config } from "@/config/config"
import { ShellTool } from "../../src/tool/shell"
import { provideInstance, testInstanceStoreLayer } from "../fixture/fixture"
import { Agent } from "../../src/agent/agent"
import { Truncate } from "@/tool/truncate"
import { SessionID, MessageID } from "../../src/session/schema"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Plugin } from "../../src/plugin"
import { testEffect } from "../lib/effect"
import { RuntimeFlags } from "@/effect/runtime-flags"

const shellLayer = Layer.mergeAll(
  LayerNode.compile(
    LayerNode.group([
      CrossSpawnSpawner.node, FSUtil.node, Plugin.node, Truncate.node,
      Config.node, Agent.node, RuntimeFlags.node,
    ]),
  ),
  testInstanceStoreLayer,
)
const it = testEffect(shellLayer)
const ctx = {
  sessionID: SessionID.make("ses_test"), messageID: MessageID.make("msg_test"),
  callID: "", agent: "build", abort: AbortSignal.any([]), messages: [],
  metadata: () => Effect.void, ask: () => Effect.void,
} as any
const projectRoot = path.join(__dirname, "../..")

describe("tool.shell spill file completeness", () => {
  it.live("spill file contains complete output for large fast-producing commands", () =>
    Effect.gen(function* () {
      const tool = yield* ShellTool
      const bash = yield* tool.init()
      const lineCount = 50000
      const result = yield* bash.execute(
        { command: `seq 1 ${lineCount}`, description: "seq spill repro" } as any,
        ctx,
      )
      const meta = result.metadata as { truncated?: boolean; outputPath?: string }
      expect(meta.truncated).toBe(true)
      expect(meta.outputPath).toBeTruthy()
      const saved = yield* (yield* FSUtil.Service).readFileString(meta.outputPath!)
      const lines = saved.trim().split(/\r?\n/)
      expect(lines[0]).toBe("1")
      expect(lines[lines.length - 1]).toBe(String(lineCount))
      expect(lines.length).toBe(lineCount)
    }).pipe(provideInstance(projectRoot)),
  )
})

Typical failure:

error: expect(received).toBe(expected)
Expected: "50000"
Received: "3735"

Note the existing test "full output is saved to file when truncated" in shell.test.ts only produces MAX_LINES + 100 lines (~10 KB), which never exceeds maxBytes while streaming — it takes the single atomic trunc.write(raw) path at the end and never exercises the streaming sink.

Root cause

In packages/opencode/src/tool/shell.ts (ShellTool.run):

  1. The output consumer is forked: Effect.forkScoped(Stream.runForEach(Stream.decodeText(handle.all), ...)). It maintains the in-memory rolling buffer (list), the spill sink writes, and the metadata previews.
  2. The main fiber then races handle.exitCode vs abort vs timeout, and returns as soon as the process exits — closing the scope.
  3. Closing the scope interrupts the forked consumer fiber. Any chunks already read off the pipe but still queued inside the stream pipeline (decodeText / runForEach backpressure) are dropped: they never reach sink.write(chunk) nor list. A sink.write can even be interrupted mid-chunk, which is how the spill file ends with a torn partial line.

For a fast producer like seq, the process exits (and the fd closes) while the consumer still has a large queue of unprocessed chunks — the race reliably wins before the consumer drains.

Fix that works

Keep the fiber handle and join it (with a grace timeout as a belt-and-braces guard) after the exit race resolves, before the scope closes:

const drain = yield* Effect.forkScoped(
  Stream.runForEach(Stream.decodeText(handle.all), (chunk) => { ... }),
)
...
// after the raceAll + kill handling:
yield* Fiber.join(drain).pipe(
  Effect.timeout("5 seconds"),
  Effect.catch(() => Effect.void),
)
return exit.kind === "exit" ? exit.code : null

With this patch on dev:

  • the repro test passes 5/5 (complete spill file, head and tail intact),
  • the full shell.test.ts + truncation.test.ts suites pass (42/42),
  • no latency regression for commands that leave a background child holding stdout (e.g. echo started; sleep 30 &): handle.exitCode already waits on stream close in that case (measured 30.29s before vs 30.30s after), so the join is a no-op there and the timeout is purely defensive.

Happy to open a PR with the test + fix if useful.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.