Repository navigation
Description
Summary
When a tool call is aborted because the command it runs stops/restarts the OpenCode background service, the session persists an assistant message containing tool_calls with no matching tool result. After the restart, the resume path dispatches the next step before that result is paired, so a strict OpenAI-compatible provider rejects the whole history with HTTP 400 invalid_request_error. Retries resend the same malformed history.
Environment
- opencode version: 2.0.21
- OS: macOS (Darwin 27.0.0, arm64)
- Terminal: OpenChamber desktop app (terminal environment variables not set)
- Shell: /bin/zsh
- Install/channel: latest (release build, not local/dev)
- Active plugins:
opencode.browser(built-in), localopenchamber-agent-tool
Reproduction
- In a session, let the agent run a shell tool whose command stops/restarts the managed background service, e.g.
opencode service unset env <VAR>(any command that restarts or kills the service). - The restart interrupts the in-flight tool. It is persisted with status
error,{"type":"aborted","message":"Tool execution interrupted: shell"}, and a synthetic "The server restarted while you were working. Continue from where you left off..." message is inserted. - On resume, a new step is dispatched to the provider while the aborted tool's result is not yet paired with its
tool_call_id. - The request therefore contains an assistant message with
tool_callsbut no correspondingtoolmessage for that call. - The request fails with 400; retries keep resending the same malformed history.
Expected Behavior
Every assistant tool_calls message sent to a provider must be immediately followed by a tool message for each tool_call_id. An aborted/interrupted call should get a synthetic error/placeholder result, or the dangling call should be dropped. Resuming after a service restart must never emit an unpaired tool_calls message.
Actual Behavior
The provider returns:
An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message)
In the application this surfaces as AI.Error.InvalidRequest and the session drain fails; the user sees "opencode failed to send message".
Additional Context
Reconstructed from the local session DB (opencode 2.0.21) — one session, all times UTC, IDs/prompts removed:
seq A: assistant message issues a shell tool call. The command restarts the service; the tool is persisted aserror/{"type":"aborted"}.seq B(+~5s): synthetic "The server restarted while you were working..." message inserted.seq C: assistant message issues the next tool call; the tool starts at timeT.- A provider request is created at
T + ~0.3sand fails — i.e. while that tool was still running and before its result was ever written. - A later retry succeeds; the session recovers on its own (or after the user sends a new message).
The same pattern repeated three times in this one session, each time immediately after a service restart (each triggered by a tool command that restarted the service). Sessions without an interruption did not reproduce it. Only strict OpenAI-compatible endpoints trip on it; providers that tolerate orphaned calls keep working.
Workaround: avoid running commands that restart/stop the managed background service inside a running turn.
Related issues: #44852, #37364, #51213, #49911 (withdrawn), #22808 (auto-closed).
Activity
github-actions commentedon Oct 1, 2026
This issue might be a duplicate of existing issues. Please check:
tool_callswithout a matching result, causing the same 400 errors on strict OpenAI-compatible providers.tool_callsand causes the same 400 response.chenhz01 commentedon Oct 1, 2026
Hi @nowzycc — the 400 comes from resuming with an assistant message that has
tool_callsbut no matchingtoolresult: the aborted call's result was never paired before the next step dispatched. On the Go side, heal the persisted session on resume — synthesize a placeholder/errortoolmessage for every danglingtool_call_id, or drop the unpairedtool_calls— so the provider never receives malformed history. Also persist the abort together with its synthetic result already paired. A regression test that aborts a tool via a service restart and then resumes would lock this in.nowzycc commentedon Oct 2, 2026
Update: a fix for this is already in flight.
#52684 (
fix(session): sanitize interrupted tool calls on transport error, basedev, currently open) changes the exact path that produces this repro, in the V2 core:packages/core/src/session/runner/to-llm-message.ts—toolResult()now synthesizes a paired error result for any tool call stuck inpending/runningthat never reached a durable settlement. Ondevtoday that function returnsundefinedfor those states, andassistant()drops theundefinedentry, so the assistant message still carries thetool_callwhile itstoolresult is missing — i.e. exactly the danglingtool_callswe see here.packages/core/src/session/runner/publish-llm-event.ts—failAssistantpublishesTool.Failedfor uncalled/unsettled tools so they do not linger aspending.This should cover the repro reported here: the failing request was built while the interrupted step's tool was still
running, so the payload had atool_callwith no matchingtoolmessage.Request: please link this issue to #52684 (or have #52684
Closesit) and keep it open until that lands, so it isn't closed as an isolated duplicate while the fix is unmerged.Regression scenario worth pinning (happy to help if useful):
opencode service unset env <VAR>), so the in-flight tool is aborted.toolresult for everytool_call(a synthesized "Tool execution was interrupted" error result is fine), and that no subsequent request fails with the strict OpenAI-compatible 400.(Small correction for the thread: the V2 runner here is TypeScript under
packages/core/src/session/runner/, not Go.)rekram1-node commentedon Oct 2, 2026
This was fixed in 9faf99a4ab and released in 2.0.22
chenhz01 commentedon Oct 7, 2026
Thanks @rekram1-node — 9faf99a in 2.0.22 covers the exact repro: the dangling
tool_callnow gets a paired synthetic result before the next step dispatches, so the provider never sees an orphaned call on resume. Healing history instead of rejecting the session is the right posture here.(Also good to see #52684 hardening the same path on the V2 core — belt and suspenders.)