Skip to content

core: interrupted tools cannot retain partial results #39565

Description

@rekram1-node

Summary

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

  1. Run a Shell tool command that periodically prints output.
  2. Interrupt the session while the command is still running.
  3. 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:

  1. Preserve interruption semantics.
  2. Allow interruption cleanup to attach partial output or metadata.
  3. Record that information on the cancelled tool outcome.
  4. Avoid requiring leaf tools to swallow interruption and return success.

Shell is the immediate example, but the abstraction should support interrupted tools generally.

Activity

  1. Dante-dan commented on Sep 10, 2026

    @Dante-dan
    Contributor

    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?

  2. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    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.sh
    set -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"

    Run it: bash repro.sh

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions