Skip to content

v2: declining a tool call is recorded as shutdown, so the declined turn resumes after a server restart #54180

Open

Description

@edwluo

Description

When the user rejects a permission request (or a question tool call is cancelled), the turn is recorded as a server shutdown. After the next server restart, the declined turn is resumed.

The step interrupts itself without a reason, and SessionExecution.terminal defaults an interrupt without a reason to "shutdown":

  • packages/core/src/session/runner/step.ts: if (tools.declines.length > 0) { ... return yield* Effect.interrupt }
  • packages/core/src/session/execution.ts: return { type: "interrupted", reason: reason ?? "shutdown" }

As a result, a declined tool call is handled exactly like a process shutdown:

  1. session.execution.interrupted is published with reason: "shutdown" while the server keeps running. Clients cannot tell a declined call from a server exit.
  2. The execution claim is kept (session_v2.time_suspended stays set) and jobs.cancel is skipped.
  3. No idle marker is projected for the turn.
  4. On the next server start, the turn is resumed. The model receives a new request whose last user message is "The server restarted while you were working. Continue from where you left off without repeating completed work." In other words, the model is told to continue the work the user just declined.

The same code is present in v2.0.12, v2.0.26 and the v2 branch at 229b422 (2026-10-09). Each half is tested separately: "defaults owner-scope interruption to shutdown" in session-execution.test.ts, and "interrupts runner continuation on a decline…" in session-runner.test.ts. Nothing tests them together.

Expected: a declined tool call ends the turn as a deliberate stop (for example reason: "user"). The claim is then released, and nothing resumes after a restart.

Plugins

None required.

OpenCode version

2.0.26

Steps to reproduce

  1. Run opencode serve --service with any model. I used a local OpenAI-compatible mock that always answers with one shell tool call.
  2. Create a session and send a prompt that triggers a tool call needing permission.
  3. Reply to the permission request with {"decision":"reject"}.
  4. The event stream shows session.execution.interrupted {"reason":"shutdown"}. In the database, select time_suspended from session_v2 where id = '<session id>' is not null. For an approved turn it is null.
  5. Restart the server. Within about one second, the session sends a new model request ending with the restart message above.

Observed on 2.0.26 (mock model, one session):

time_suspended idle markers model requests
after reject set 0 1
after server restart NULL 1 (succeeded) 2

After the restart, the session history reads: user prompt → assistant shell call (declined) → synthetic restart message → new assistant reply → idle succeeded. The turn the user declined now ends as succeeded.

Operating System

Ubuntu 24.04 LTS (x86_64)

Terminal

N/A (HTTP API against opencode serve --service)

Activity

  1. edwluo commented on Oct 9, 2026

    @edwluo
    Author

    Also reproduced on 2.0.26 with a cancelled question tool call (DELETE /api/session/<id>/form/<form>): form.cancelled → session.tool.failed ("The user dismissed this question") → session.step.failed ("Step interrupted") → session.execution.interrupted {"reason":"shutdown"}, and time_suspended stays set.

  2. opencode-agent commented on Oct 9, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I reproduced this on 2.0.26, and it still happens on the latest v2 (229b422).

    Steps (simulated model, database kept across restarts):

    1. Set shell to ask. The model replies with a single shell call.
    2. Reject the permission request. The tool call shows "The user declined this tool call" and the turn shows "interrupted". The model has received 1 request.
    3. Restart the server with the same database.
    4. Within a few seconds the session sends a second model request containing "The server restarted while you were working…", so the declined turn resumes.

    The script below reproduces it, and the recording and screenshot show the reject step in the TUI. I only counted model requests; I didn't check the interrupted event or time_suspended myself. What I saw matches your analysis of the decline path in step.ts and the "shutdown" default in execution.ts.

    Related: #36347 (keeping permission/question waits across restarts) and #35646 (auto-resume after a restart).

    recording.mp4

    Screenshot

    Reproduction script (restart.ts)
    // Reproduces #54180: rejecting a permission request is recorded as a shutdown,
    // so the declined turn is resumed after the server restarts.
    // Run: OPENCODE_DRIVE_DB=restart.sqlite OPENCODE_DRIVE_MEDIA_DIR=/workspace/media \
    //        opencode-drive start --name repro54180 --script ./restart.ts   [add --dev <checkout> to run from source]
    // Expected: 1 model request in total. Actual: a 2nd request containing "The server restarted while you were working" after restart.
    import { Effect, Stream } from "effect"
    import { defineScript, Llm } from "opencode-drive"
    
    export default defineScript({
      launch: "manual",
      config: { permissions: [{ action: "*", resource: "*", effect: "allow" }, { action: "shell", resource: "*", effect: "ask" }] } as any,
      run: ({ server, tuis, llm }) =>
        Effect.gen(function* () {
          const requests: string[] = []
          yield* llm.serve((request: any, index) => {
            const text = JSON.stringify(request)
            requests.push(text)
            console.log(`[repro] model request #${index + 1}; mentions restart: ${text.includes("server restarted")}`)
            return index === 0
              ? Stream.make(
                  Llm.toolCall({ index: 0, id: "call_shell", name: "shell", input: { command: "echo hi", description: "say hi" } }),
                  Llm.finish("tool-calls"),
                )
              : Stream.make(Llm.text("Continuing the declined work."))
          })
          yield* server.launch()
          const a = yield* tuis.launch("a", { recording: true })
          yield* a.ui.submit("Run echo hi")
          yield* a.ui.waitFor("Reject", { timeout: 20000 } as any)
          yield* a.ui.screenshot("permission")
          // Choose "Reject" in the permission prompt
          const reject = yield* a.ui.getNode({ role: "option", label: "Reject" } as any)
          yield* a.ui.click(reject)
          yield* Effect.sleep(3000)
          yield* a.ui.screenshot("after-reject")
          console.log(`[repro] model requests after reject: ${requests.length}`)
          yield* a.close()
          yield* server.kill()
          yield* server.launch()
          yield* Effect.sleep(5000)
          const b = yield* tuis.launch("b", { recording: true })
          yield* Effect.sleep(3000)
          yield* b.ui.screenshot("after-restart")
          console.log(`[repro] model requests after restart: ${requests.length}; last mentions restart: ${requests.at(-1)?.includes("server restarted")}`)
        }),
    })

    Run it with opencode-drive: opencode-drive run ./restart.ts. It drives opencode from your PATH.

  3. argszero commented on Oct 9, 2026

    @argszero
    Contributor

    I'd like to work on this.

    Confirmed on the current v2 (8eff035): the decline path in session/runner/step.ts interrupts the runner fiber without a reason, and session/execution.ts defaults an interrupt that carries no reason to "shutdown" — so a user decline settles exactly like a process exit: the claim is kept, jobs.cancel is skipped, no idle marker is projected, and the declined turn is resumed after a restart.

    I plan to give a declined tool call its own deliberate stop reason so the claim is released and nothing resumes, plus a test that exercises the decline and the terminal settlement together — as you note, the two halves are only tested apart today.

  4. argszero commented on Oct 10, 2026

    @argszero
    Contributor

    Implemented in #54344.

    A declined tool call now reports a deliberate stop before it interrupts, so the settle path no longer reads the reason-less interrupt as shutdown: step.ts calls input.reportDeclined() ahead of its Effect.interrupt, and execution.ts wires that to coordinator.stopReason(sessionID, "user"). jobs.cancel then runs and the claim is released, so the declined turn does not resume after a restart.

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions