Repository navigation
Description
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:
session.execution.interruptedis published withreason: "shutdown"while the server keeps running. Clients cannot tell a declined call from a server exit.- The execution claim is kept (
session_v2.time_suspendedstays set) andjobs.cancelis skipped. - No idle marker is projected for the turn.
- 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
- Run
opencode serve --servicewith any model. I used a local OpenAI-compatible mock that always answers with oneshelltool call. - Create a session and send a prompt that triggers a tool call needing permission.
- Reply to the permission request with
{"decision":"reject"}. - 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. - 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
edwluo commentedon Oct 9, 2026
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"}, andtime_suspendedstays set.opencode-agent commentedon Oct 9, 2026
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):
shelltoask. The model replies with a singleshellcall.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
interruptedevent ortime_suspendedmyself. What I saw matches your analysis of the decline path instep.tsand the"shutdown"default inexecution.ts.Related: #36347 (keeping permission/question waits across restarts) and #35646 (auto-resume after a restart).
recording.mp4
Reproduction script (restart.ts)
Run it with opencode-drive:
opencode-drive run ./restart.ts. It drivesopencodefrom yourPATH.argszero commentedon Oct 9, 2026
I'd like to work on this.
Confirmed on the current
v2(8eff035): the decline path insession/runner/step.tsinterrupts the runner fiber without a reason, andsession/execution.tsdefaults an interrupt that carries no reason to"shutdown"— so a user decline settles exactly like a process exit: the claim is kept,jobs.cancelis 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.
argszero commentedon Oct 10, 2026
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.tscallsinput.reportDeclined()ahead of itsEffect.interrupt, andexecution.tswires that tocoordinator.stopReason(sessionID, "user").jobs.cancelthen runs and the claim is released, so the declined turn does not resume after a restart.fix(core): settle a declined tool call as the user's stop