Skip to content

Interrupted tool calls permanently corrupt session history (orphaned tool_use without tool_result) #21326

Closed

Description

@liqiyuan3

Description

Bug: Interrupted tool calls permanently corrupt session history

Problem

When a tool call is interrupted (network timeout, proxy error, abort), OpenCode records the tool_use block in the assistant message but does not insert a corresponding tool_result block (even a synthetic error one). This makes the session permanently broken -- every subsequent message attempt is rejected by the Claude API with:

messages.N: `tool_use` ids were found without `tool_result` blocks immediately after: toolu_bdrk_XXXX

The session cannot recover without manual intervention (revert/undo).

Compounding issue: empty error in TUI

When this validation error occurs, session.error fires with an empty error field, and the TUI shows zero feedback. The user sees a silent hang with no indication of what went wrong.

Root Cause

The Go core does not ensure message history integrity when tool execution is interrupted. The fix should be: always insert a tool_result with error status when a tool_use does not complete, regardless of the interruption cause.

Evidence

Session errors occurring 1-4ms after user message (pre-request validation, not network timeout):

11:04:35.902  user_message
11:04:35.906  session_error  error:""  (4ms -- request never left the client)

Full Bedrock error when error IS surfaced:

InvokeModelWithResponseStream: operation error Bedrock Runtime,
StatusCode: 400, ValidationException:
messages.53: tool_use ids were found without tool_result blocks
immediately after: toolu_bdrk_012CSgTxpSpwK877z7LK5M2Y

This affects both old sessions AND newly created sessions (corruption happens actively).

Expected Behavior

  1. When a tool call is interrupted, insert a synthetic tool_result with error status to maintain message format compliance
  2. session.error should include the actual error message, not an empty string
  3. TUI should show visible feedback on any session error

Workaround

Plugin that hooks session.error, scans for orphaned tool_use blocks, and attempts session.revert().

Environment

  • OpenCode: latest
  • OS: Windows 11
  • Provider: API proxy -> AWS Bedrock -> Claude Sonnet 4 / Opus 4
  • Frequency: Multiple times per day

Plugins

No response

OpenCode version

No response

Steps to reproduce

No response

Screenshot and/or share link

No response

Operating System

No response

Terminal

No response

Activity

  1. added
    coreAnything pertaining to core functionality of the application (opencode server stuff)
    on Apr 7, 2026
  2. github-actions commented on Apr 7, 2026

    @github-actions
    Contributor

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

    If your issue is distinct or adds new context not covered by the above, please clarify the differences so it can be tracked separately.

  3. bvironn commented on Apr 11, 2026

    @bvironn

    This affects me as well — v1.4.3, WSL2/Linux, Claude Opus 4.6 via Anthropic API. Sessions become permanently unrecoverable once hit.

    Regarding the bot's duplicate flag: this is the same symptom as #1662 and #10616, but the root cause has multiple entry points. The partial fixes applied so far address individual vectors but miss the systemic issue. Adding source-level analysis below.


    Healing logic exists but is insufficient

    There IS healing logic in message-v2.ts (lines 779-790) that catches pending/running tool calls and converts them to output-error parts:

    if (part.state.status === "pending" || part.state.status === "running")
      assistantMessage.parts.push({
        type: ("tool-" + part.tool),
        state: "output-error",
        toolCallId: part.callID,
        input: part.state.input,
        errorText: "[Tool execution was interrupted]",
      })

    And processor.ts (lines 489-512) marks interrupted tool calls as status: "error" with metadata: { interrupted: true } during cleanup. All four ToolState values (pending, running, completed, error) are handled in toModelMessages.

    Despite this, the bug persists because three code paths bypass the healing entirely.

    Vector 1 — normalizeMessages in transform.ts (lines 56-74)

    For Anthropic/Bedrock providers, messages with empty content after filtering get dropped:

    const filtered = msg.content.filter((part) => {
      if (part.type === "text" || part.type === "reasoning") {
        return part.text !== ""
      }
      return true
    })
    if (filtered.length === 0) return undefined // entire message removed

    This runs AFTER convertToModelMessages has already separated tool_use and tool_result into distinct ModelMessage entries. If a message in the ModelMessage[] chain gets eliminated here, it breaks the tool_use → tool_result pairing that the Anthropic API requires.

    Vector 2 — Error-skip in message-v2.ts (lines 697-705)

    if (msg.info.error && !(
      MessageV2.AbortedError.isInstance(msg.info.error) &&
      msg.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning")
    )) {
      continue // entire assistant message skipped
    }

    When a previous assistant message failed with an APIError (including this exact tool_use without tool_result error), the message is skipped entirely on the next attempt. But its tool parts may have already influenced the ModelMessage[] chain via convertToModelMessages. The skip removes the message but not its downstream effects.

    Vector 3 — Lost step boundaries during retries (#16749)

    When the finish-step handler throws during a retryable error, step-finish for step N and step-start for step N+1 are never persisted. Both steps' content merges into a single DB message. On replay, convertToModelMessages produces interleaved tool_use/text/reasoning content in a single assistant block — which Anthropic rejects. #16749 documents this in detail with SQL evidence from a real session.

    The retry policy doesn't help

    In retry.ts, retryable() returns undefined for non-retryable errors. Anthropic's 400 Bad Request (isRetryable: false) means the corrupted request fails permanently on first attempt — no retry, no recovery.

    This is a long-standing systemic issue

    This has been open since #1662 (February 2026). Related issues include #8377, #10616, #16749, #17065, #14367, #21489, #2720, #5750, #2214, #8312, #8010. Partial fixes have been applied to individual vectors, but the core problem persists because each fix targets one entry point while the others remain open.

    Suggested fix: post-transform validation at the ModelMessage[] level

    The healing logic in toModelMessages and the cleanup in processor.ts work at the part level — before the data passes through convertToModelMessages and normalizeMessages. All three vectors corrupt the data after that point.

    The fix that catches all three vectors is a validation pass on the final ModelMessage[] array, after both convertToModelMessages and normalizeMessages have run. Pseudocode:

    function ensureToolResultIntegrity(messages: ModelMessage[]): ModelMessage[] {
      // 1. Collect all tool_use IDs from assistant messages
      // 2. Collect all tool_result IDs from tool messages
      // 3. For any tool_use ID without a matching tool_result:
      //    - Inject a synthetic tool_result with error content, OR
      //    - Remove the orphaned tool_use block
      // 4. Ensure no assistant message has text/reasoning after tool_use
      //    (split into separate messages if needed)
      return sanitized
    }

    This is the only point in the pipeline that sits downstream of all three corruption vectors. Fixing individual vectors is still worthwhile, but without this safety net, new edge cases will keep producing the same symptom.

  4. rekram1-node commented on Apr 16, 2026

    @rekram1-node
    Contributor

    fix: 348a849

  5. phazei commented on May 5, 2026

    @phazei

    Totally not fixed

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

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingcoreAnything pertaining to core functionality of the application (opencode server stuff)windows

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions