Skip to content

session: tool aborted by a background-service restart leaves unpaired tool_calls, producing 400 on resume #52452

Closed

Description

@nowzycc

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), local openchamber-agent-tool

Reproduction

  1. 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).
  2. 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.
  3. 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.
  4. The request therefore contains an assistant message with tool_calls but no corresponding tool message for that call.
  5. 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 as error / {"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 time T.
  • A provider request is created at T + ~0.3s and 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

  1. github-actions commented on Oct 1, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

    • #44852: A failed or aborted tool call leaves tool_calls without a matching result, causing the same 400 errors on strict OpenAI-compatible providers.
    • #49911 (withdrawn): An errored tool part is missing from provider history, producing the same unpaired-call error.
    • #22808: Interrupting a tool call leaves dangling tool_calls and causes the same 400 response.
    • #21326: Interrupted tool calls leave unmatched results and corrupt resumed session history.
  2. chenhz01 commented on Oct 1, 2026

    @chenhz01

    Hi @nowzycc — the 400 comes from resuming with an assistant message that has tool_calls but no matching tool result: 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/error tool message for every dangling tool_call_id, or drop the unpaired tool_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.

  3. nowzycc commented on Oct 2, 2026

    @nowzycc
    Author

    Update: a fix for this is already in flight.

    #52684 (fix(session): sanitize interrupted tool calls on transport error, base dev, 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 in pending/running that never reached a durable settlement. On dev today that function returns undefined for those states, and assistant() drops the undefined entry, so the assistant message still carries the tool_call while its tool result is missing — i.e. exactly the dangling tool_calls we see here.
    • packages/core/src/session/runner/publish-llm-event.ts — failAssistant publishes Tool.Failed for uncalled/unsettled tools so they do not linger as pending.

    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 a tool_call with no matching tool message.

    Request: please link this issue to #52684 (or have #52684 Closes it) 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):

    1. Start a turn and run a tool whose command restarts the managed service (e.g. opencode service unset env <VAR>), so the in-flight tool is aborted.
    2. After the restart, let the session resume and dispatch the next step while the previous tool is still unsettled.
    3. Assert the request built for that step contains a paired tool result for every tool_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.)

  4. rekram1-node commented on Oct 2, 2026

    @rekram1-node
    Contributor

    This was fixed in 9faf99a4ab and released in 2.0.22

  5. chenhz01 commented on Oct 7, 2026

    @chenhz01

    Thanks @rekram1-node — 9faf99a in 2.0.22 covers the exact repro: the dangling tool_call now 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.)

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions