Skip to content

codemode: interrupting execute discards results of nested calls that already finished #54222

Open

Description

@kitlangton

Summary

When an execute call is interrupted, the model only gets Tool execution interrupted. Results from nested tool calls that had already finished, and anything the script logged, are discarded, even though the stored tool part lists those calls as completed.

Environment

  • opencode version: 0.0.0-dev-20788 (V2, v2 at 8eff035)
  • OS: macOS (Darwin 27.0.0, arm64)

Reproduction

  1. Use the slow plugin tool from codemode: execute and plugin tool calls have no timeout, so one slow call blocks the session until interrupted #54220.

  2. Have the agent run this through execute:

    const first = await tools.slow_wait({ seconds: 1 })
    console.log("first:", first)
    await tools.slow_wait({ seconds: 600 })
    return first
  3. Interrupt the session while the second call is running.

Expected Behavior

The interrupted (or timed out) tool result still contains the finished nested results, or a truncated preview of each, along with the logs. The next turn can then use them without redoing the work.

Actual Behavior

The tool part ends as { type: "aborted", message: "Tool execution interrupted" } with no output. metadata.toolCalls shows the first call as completed, but its result and the console.log line are gone:

  • On interrupt, the program re-raises the interruption (execute.ts#L92-L95), so logs and the results of finished calls never reach formatResult.
  • The progress hooks record only each nested call's name, status and input (codemode/tool.ts#L143-L176).
  • The step runner then fails the unsettled tool with the generic error (step.ts#L221-L228).

Proposal

Activity

  1. opencode-agent commented on Oct 9, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I reproduced this from source on the latest v2 (8eff035bf4, the same commit you reported).

    What I did: I registered a dynamic slow_wait tool and had the simulated model run your script through execute. The first nested call finished with waited 1s: FIRST_RESULT_42. I interrupted (Esc) while the 600s call was running, then sent a follow-up prompt.

    The recording below shows the TUI marking the first slow_wait call as finished. However, the follow-up request sends the model only this for the execute call:

    {"error":{"type":"aborted","message":"Tool execution interrupted"},"content":[]}
    

    The first result and the console.log output are both missing. A standalone test in packages/codemode gave the same result: the call hooks report the first call as success, but execute ends as a bare interruption, so logs and the finished results never reach the result. That fits your reading of execute.ts. The script below reproduces it with opencode-drive.

    recording.mp4

    Screenshot

    Reproduction script (drive.ts)
    import { Effect, Stream } from "effect"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    const code = `const first = await tools.slow_wait({ seconds: 1 })
    console.log("first:", first)
    await tools.slow_wait({ seconds: 600 })
    return first`
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        tui: { recording: true, keypressOverlay: true },
      },
      ({ ui, llm, tools }) =>
        Effect.gen(function* () {
          yield* tools.attach({
            tools: [
              {
                name: "slow_wait",
                description: "Wait for the given number of seconds",
                inputSchema: { type: "object", properties: { seconds: { type: "number" } }, required: ["seconds"] },
                outputSchema: { type: "object", properties: { waited: { type: "number" } }, required: ["waited"] },
              },
            ],
          })
          const bodies: Array<unknown> = []
          yield* llm.serve((request, index) => {
            bodies.push(request.body)
            return index === 0
              ? Stream.make(Llm.toolCall({ index: 0, id: "call_exec", name: "execute", input: { code } }), Llm.finish("tool-calls"))
              : Stream.make(Llm.text("Follow-up turn."))
          })
          yield* ui.submit("Run the slow script")
          const first = yield* tools.take()
          yield* Effect.log(`first nested call: ${JSON.stringify(first.input)}`)
          yield* Effect.sleep(1000)
          yield* first.finish({ structured: { waited: 1 }, content: [{ type: "text", text: "waited 1s: FIRST_RESULT_42" }] })
          const second = yield* tools.take()
          yield* Effect.log(`second nested call: ${JSON.stringify(second.input)}`)
          yield* Effect.sleep(1500)
          yield* ui.screenshot("before-interrupt")
          yield* ui.press("escape")
          yield* Effect.sleep(300)
          yield* ui.press("escape")
          yield* Effect.sleep(2500)
          yield* ui.screenshot("after-interrupt")
          // Ask a follow-up so we can see what the model receives for the interrupted execute call.
          yield* ui.submit("What did the first call return?")
          yield* ui.waitFor("Follow-up turn.")
          yield* ui.screenshot("follow-up")
          const follow = JSON.stringify(bodies.at(-1))
          const at = follow.indexOf("call_exec", follow.indexOf("call_exec") + 1)
          const messages = (bodies.at(-1) as { messages?: Array<{ role: string; tool_call_id?: string; content?: unknown }> }).messages ?? []
          const toolMsg = JSON.stringify(messages.find((m) => m.role === "tool" && m.tool_call_id === "call_exec")?.content ?? null)
          yield* Effect.log(`execute result sent to the model on the next turn:\n${follow.slice(Math.max(0, at - 200), at + 600)}`)
          yield* Effect.log(`execute tool result contains FIRST_RESULT_42: ${toolMsg.includes("FIRST_RESULT_42")}; contains the logged "first:" line: ${toolMsg.includes("first:")}\n${toolMsg}`)
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

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