anomalyco / anomalyco/opencode
shell tool: truncation spill file is incomplete (tail lost, torn lines) — stream consumer interrupted at process exit
@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):
- 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. - The main fiber then races
handle.exitCodevs abort vs timeout, and returns as soon as the process exits — closing the scope. - 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)norlist. Asink.writecan 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.tssuites pass (42/42), - no latency regression for commands that leave a background child holding stdout (e.g.
echo started; sleep 30 &):handle.exitCodealready 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.