You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
V2 tool execution uses Effect interruption, which prevents interrupted tools from attaching partial output to their terminal result. This causes useful diagnostics, such as Shell output produced before cancellation, to be lost.
Environment
opencode version: V2 development branch
OS: All
Terminal: Not applicable
Shell: Not applicable
Install/channel: V2 development
Active plugins: Not applicable
Reproduction
Run a Shell tool command that periodically prints output.
Interrupt the session while the command is still running.
Inspect the interrupted tool result.
Expected Behavior
The tool should remain interrupted while retaining partial output or other useful result data produced before interruption.
For example, a Shell command interrupted after ten seconds should retain the output generated during those ten seconds.
Actual Behavior
Effect interruption prevents the tool executor from returning a normal result. The Shell interruption handler removes the Shell record and output file, so the partial output is unavailable.
Unlike V1, the leaf tool cannot convert cancellation into a successful result because V2 requires interruption to propagate.
Additional Context
This is broader than the Shell tool. Any V2 tool performing incremental work may need to attach partial results or diagnostics to an interrupted terminal outcome.
The likely solution belongs at the Job or tool-execution boundary:
Preserve interruption semantics.
Allow interruption cleanup to attach partial output or metadata.
Record that information on the cancelled tool outcome.
Avoid requiring leaf tools to swallow interruption and return success.
Shell is the immediate example, but the abstraction should support interrupted tools generally.
I traced the current V2 boundary and this still looks broader than Shell: Tool.Context has no checkpoint/progress capability, while Effect interruption prevents a normal tool result from reaching settlement. The session layer already has replayable tool.progress events, and failed tool states can retain prior structured content, but the runner does not connect those pieces.
I propose a bounded design before implementation:
let a running tool publish a checkpoint through its context;
persist that checkpoint through the existing progress event path;
keep interruption as a failed/interrupted terminal state rather than converting it to success;
project the latest checkpoint into that terminal state, including managed-output references where applicable.
I would validate this with an interruptible Shell fixture that emits output before cancellation, plus a generic tool fixture: both should remain interrupted, retain the latest checkpoint after replay, and preserve explicit removal semantics. I also checked the closed narrow Shell PR #39562; its author closed it specifically in favor of this broader design, and I found no active replacement PR.
Would the core team approve this boundary and assign the issue before I implement it?
Thanks for the report. I reproduced this on the latest dev (c63a939) with a core test that runs the real Shell tool on a loop printing tick-N every 200 ms, then interrupts the call after about a second:
Before the interrupt:shell.output(id) returned tick-1 through tick-7.
The tool call's result: a pure interrupt cause, with no output or metadata attached.
After the interrupt:shell.get(id) returned NotFound and the shell's .out file had been deleted, so the partial output was lost.
The cause matches your description. On interrupt, tool/plugin/shell.ts runs shell.remove(info.id) (around shell.result), and the jobs.block interrupt handler only calls jobs.cancel. Neither path records anything on the cancelled outcome. This code is the same in v2.2.0 and on dev.
I couldn't make a TUI recording: in my opencode-drive attempt, the simulated model's shell call never showed on screen. The script below shows the bug directly. It temporarily appends the test to packages/core/test/tool-shell.test.ts and runs it.
Reproduction script (repro.sh)
#!/usr/bin/env bash# Repro for #39565: interrupting a foreground V2 Shell tool call loses its partial output.# Run from an opencode checkout (dev or v2.2.0) after `bun install`:# REPO=/path/to/opencode bash repro.shset -euo pipefail
REPO=${REPO:-/workspace/repo}cd"$REPO/packages/core"
cp test/tool-shell.test.ts /tmp/tool-shell.test.ts.bak
trap'cp /tmp/tool-shell.test.ts.bak test/tool-shell.test.ts' EXIT
cat >> test/tool-shell.test.ts <<'TEST'describe("repro #39565", () => { it.live("interrupting a foreground shell discards its partial output", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), (tmp) => { reset() return withSession(tmp.path, (registry) => Effect.gen(function* () { const shell = yield* Shell.Service const scope = yield* Scope.Scope const fiber = yield* executeTool( registry, call({ command: "for i in $(seq 1 60); do echo tick-$i; sleep 0.2; done" }, "call-interrupt"), ).pipe(Effect.exit, Effect.forkIn(scope, { startImmediately: true })) let info: Shell.Info | undefined for (let i = 0; i < 200 && !info; i++) { info = (yield* shell.list())[0] if (!info) yield* Effect.sleep(Duration.millis(10)) } if (!info) throw new Error("shell never started") yield* Effect.sleep(Duration.millis(1200)) const before = yield* shell.output(info.id) console.log("REPRO partial output before interrupt:", JSON.stringify(before).slice(0, 200)) yield* Fiber.interrupt(fiber) const exit = yield* Fiber.await(fiber) console.log("REPRO tool exit:", JSON.stringify(exit).slice(0, 300)) const after = yield* shell.get(info.id).pipe(Effect.exit) console.log("REPRO shell.get after interrupt:", Exit.isFailure(after) ? "NotFound (record removed)" : "present") const fileExists = yield* Effect.promise(() => fs.stat(info!.file).then(() => true, () => false)) console.log("REPRO output file exists after interrupt:", fileExists, info.file) }), ) }, (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)), ), )})TEST
bun test test/tool-shell.test.ts -t "repro #39565"2>&1| grep -E "REPRO|pass|fail"
Activity
Dante-dan commentedon Sep 10, 2026
I traced the current V2 boundary and this still looks broader than Shell:
Tool.Contexthas no checkpoint/progress capability, while Effect interruption prevents a normal tool result from reaching settlement. The session layer already has replayabletool.progressevents, and failed tool states can retain prior structured content, but the runner does not connect those pieces.I propose a bounded design before implementation:
I would validate this with an interruptible Shell fixture that emits output before cancellation, plus a generic tool fixture: both should remain interrupted, retain the latest checkpoint after replay, and preserve explicit removal semantics. I also checked the closed narrow Shell PR #39562; its author closed it specifically in favor of this broader design, and I found no active replacement PR.
Would the core team approve this boundary and assign the issue before I implement it?
opencodejr commentedon Oct 28, 2026
Thanks for the report. I reproduced this on the latest
dev(c63a939) with a core test that runs the real Shell tool on a loop printingtick-Nevery 200 ms, then interrupts the call after about a second:shell.output(id)returnedtick-1throughtick-7.shell.get(id)returned NotFound and the shell's.outfile had been deleted, so the partial output was lost.The cause matches your description. On interrupt,
tool/plugin/shell.tsrunsshell.remove(info.id)(aroundshell.result), and thejobs.blockinterrupt handler only callsjobs.cancel. Neither path records anything on the cancelled outcome. This code is the same in v2.2.0 and ondev.I couldn't make a TUI recording: in my opencode-drive attempt, the simulated model's shell call never showed on screen. The script below shows the bug directly. It temporarily appends the test to
packages/core/test/tool-shell.test.tsand runs it.Reproduction script (repro.sh)
Run it:
bash repro.sh