Skip to content

[BUG] stripReasoningFromMergedAssistants mutates thinking/redacted_thinking blocks in the last assistant message, causing Anthropic 400 #161

@rustybret

Description

@rustybret

Summary

stripReasoningFromMergedAssistants has no guard for the last (most recent) assistant message. When the last assistant message is not firstInRun — i.e., the preceding message also has role: "assistant" — every thinking and redacted_thinking block in it is replaced with makeSentinel() ({ type: "text", text: "" }). The next API request carries these mutated blocks and Anthropic rejects the request with:

400: messages.1.content.80: `thinking` or `redacted_thinking` blocks in the latest
assistant message cannot be modified. These blocks must remain as they were in the
original response.

Root cause

// dist/index.js ~line 185009
function stripReasoningFromMergedAssistants(messages, providerID) {
  if (providerID !== 'anthropic') return 0;
  for (const message of messages) {
    // NO last-message guard — iterates every message including the most recent one
    const firstInRun = prevRole !== 'assistant';
    // if prevRole === 'assistant' → firstInRun = false → keepIndex = -1
    // → ALL thinking/redacted_thinking blocks get sentinelized:
    message.parts[i] = makeSentinel(part); // { type: 'text', text: '' }
  }
}

Anthropic's constraint applies to blocks returned by the most recent API response — any mutation of those blocks in the subsequent request triggers the 400. The function must skip the last assistant message (or any message whose tag number is above the protected-tag watermark).

Reproduction

  1. Use an Anthropic model with extended thinking enabled in OpenCode.
  2. Run a prompt that produces a thinking block in the last assistant turn.
  3. Trigger a slash command (e.g. /ctx_reduce, /handoff, any magic-context command) that causes experimental.chat.messages.transform to fire.
  4. If the previous message in the conversation also has role: "assistant", the next API call fails with the 400 above.

Expected behaviour

stripReasoningFromMergedAssistants must never sentinelize thinking blocks in the most recent assistant message. A minimal fix:

for (let msgIdx = 0; msgIdx < messages.length; msgIdx++) {
  const message = messages[msgIdx];
  const isLastAssistantMessage =
    msgIdx === messages.length - 1 && message.info.role === 'assistant';
  if (isLastAssistantMessage) {
    prevRole = message.info.role;
    continue; // never touch the last assistant message's thinking blocks
  }
  // ...existing logic...
}

A more precise fix would use the protectedTags watermark already threaded through other parts of the pipeline, so the guard is tag-based rather than position-based.

Severity

P0 — triggers on every slash command invocation when extended thinking is active and the last assistant message is not firstInRun.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions