Skip to content

V2: errored-message replay can 400 with Anthropic thinking + tool use #38620

Closed

Description

@rekram1-node

Problem

In the V2 session core, replaying history that contains an errored assistant message can produce a request Anthropic rejects with a 400 when extended thinking is enabled.

reuseProviderMetadata requires message.error === undefined (packages/core/src/session/runner/to-llm-message.ts:114), so an aborted/failed step drops all reasoning state on replay — both signed thinking (anthropic.signature) and redacted thinking (anthropic.redactedData) — while still replaying that same message's tool_use parts.

Anthropic requires a tool_use block to be preceded by its thinking/redacted_thinking block when thinking is enabled. Replaying tool_use without the reasoning block yields a 400 from the API.

Impact

Multi-turn thinking + tool-use conversations with Anthropic break after an interrupt or failure mid-step: the follow-up request replays the errored message's tool calls stripped of their reasoning blocks and the provider rejects it.

Scope

  • Pre-existing behavior; unrelated to and not introduced by feat(ai): round-trip Anthropic redacted thinking blocks #38614 (which added redacted thinking round-trip support in packages/ai). Signed thinking is affected identically.
  • The errored-message guard is otherwise a deliberate design decision, so the fix should be deliberate too.

Possible directions

  • Relax the message.error === undefined guard in to-llm-message.ts so reasoning provider state is reused for errored messages when provider+model match, or
  • Drop the errored message's tool-call parts alongside the reasoning so the replayed message stays self-consistent (no orphaned tool_use).

Either way, the invariant to preserve is: never replay a tool_use part to Anthropic without its preceding thinking block while thinking is enabled.

Activity

  1. added
    bugSomething isn't working
    coreAnything pertaining to core functionality of the application (opencode server stuff)
    on Jul 24, 2026
  2. MagnumGoYB commented on Aug 3, 2026

    @MagnumGoYB
    Contributor

    I'd like to take this one, if the direction is still open.

    Two things I'd like to confirm before writing any code, since the issue notes the errored-message guard is a deliberate decision:

    1. Which direction do you prefer? Of the two listed, dropping the errored message's tool-call parts alongside the reasoning seems safer to me than relaxing the message.error === undefined guard, because it keeps the replayed message self-consistent without re-introducing provider state from a step that never completed. But I may be missing why the guard is scoped the way it is.

    2. Should the PR target dev or v2? The guard is present on both (to-llm-message.ts:73 on dev, :114 on v2, which matches the line in the issue). I'd rather ask up front, since PRs based on v2 seem to get flagged needs:issue because GitHub doesn't populate closing references for a non-default base.

    Happy to add regression coverage for the invariant either way: never replay a tool_use part without its preceding thinking block while thinking is enabled.

  3. MagnumGoYB commented on Aug 3, 2026

    @MagnumGoYB
    Contributor

    Follow-up before I write anything, because a local check changed my read of the two directions.

    The current behavior is not just incidental, it is pinned by an existing test: packages/core/test/session-runner-message.test.ts:330, "drops provider-native continuation metadata from failed assistant turns". It asserts that an errored turn replays reasoning with providerMetadata: undefined and keeps the tool-call / tool-result parts. So that test encodes exactly the shape this issue says Anthropic rejects.

    Baseline on dev: 6 pass in that file.

    That means either direction has to touch that test, which makes the choice less about the fix site and more about which invariant you want asserted:

    • Relaxing the message.error === undefined guard means the test's premise changes: errored turns would keep signed/redacted thinking when provider+model match. The test would assert reuse instead of dropping.
    • Dropping the errored turn's tool-call parts keeps the "no provider state reused" property the test is protecting, and instead removes the orphaned tool_use. The test's reasoning assertion stays as-is; its tool assertions go away.

    The second still looks preferable to me for that reason: it preserves what the existing test was written to protect. But that test's title suggests the drop was a deliberate cross-provider decision (the fixture is OpenAI reasoningEncryptedContent, not Anthropic), so I don't want to repurpose it without a maintainer's call.

    One more thing worth flagging: the same guard also feeds the OpenAI path at line 299 ("restores OpenAI encrypted reasoning metadata"), so relaxing it is not Anthropic-scoped.

    Still happy to do this, just want the direction confirmed first given the test coupling. Same question on dev vs v2 as above.

  4. ggbdpq commented on Sep 28, 2026

    @ggbdpq

    Root cause confirmed: assistant() in packages/core/src/session/runner/to-llm-message.ts computes reuseProviderMetadata = sameModel && message.error === undefined, so an errored turn replays its tool-call parts with all reasoning provider state (signatures / redacted data) stripped — Anthropic then rejects the request because a tool_use block arrives without its preceding thinking block.

    I’ll go with the second direction: drop the errored message’s tool parts alongside the reasoning (including their paired tool results, so no orphaned tool_result either). That keeps the “no provider state reused from a step that never completed” property the existing test protects, and unlike relaxing the guard it does not change the OpenAI encrypted-reasoning path.

    Plan: regression-test the invariant (an errored turn must not replay tool_use/tool_result without its thinking blocks), update the existing “drops provider-native continuation metadata from failed assistant turns” assertions to the new contract, and open a PR against dev.

  5. ggbdpq commented on Sep 28, 2026

    @ggbdpq

    Follow-up: I implemented direction 2 first, but a local test changed my read — same hazard the existing test coupling flagged above.

    packages/core/test/session-runner.test.ts (“durably fails blocked local tools when a provider turn is interrupted”) pins a deliberate recovery design: an interrupted turn’s blocked local tool is durably failed and its errored assistant turn replays the tool call plus the error tool result (["user", "assistant", "tool"]) so the model can see the failure and recover. Direction 2 breaks that recovery flow, so I switched to the issue’s first direction, scoped exactly as worded: reuse reasoning provider state (signed/redacted thinking) for errored messages when provider+model match, while tool continuation metadata stays dropped.

    That keeps the invariant (a replayed tool_use is preceded by its thinking block), leaves the recovery test untouched, and only flips the reasoning premise of “drops provider-native continuation metadata from failed assistant turns” (its tool assertions are unchanged). PR against dev coming.

  6. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks for the detailed write-up. I couldn't confirm the Anthropic 400 here because there are no provider credentials in this sandbox, but I did confirm the request shape you describe on the latest dev (c63a939).

    I passed an assistant message for anthropic/claude-sonnet-4-5 through toLLMMessages and then through the Anthropic Messages compileRequest. The message held signed thinking (signature), redacted thinking (redactedData) and a read tool call:

    • Not errored: the assistant content is thinking (with the signature), then redacted_thinking, then tool_use. That is correct.
    • error set ("Step interrupted"): the assistant content is text, then tool_use. The signed thinking becomes plain text, the redacted block is dropped, and the tool_use is still sent without a leading thinking block.

    This matches the message.error === undefined check on reuseProviderMetadata in packages/core/src/session/runner/to-llm-message.ts. An existing test in session-runner-message.test.ts expects errored reasoning to be replayed as plain text, so the open question is only the tool_use that stays in the message.

    Confirming the actual 400 needs someone with an Anthropic account and extended thinking enabled. The key case is an interrupted message being replayed as the last assistant turn of a tool-use loop, which is where Anthropic requires the leading thinking block.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions