Skip to content

normalizeMessages() removes empty text parts between reasoning blocks, invalidating Anthropic thinking block signatures #16748

@altendky

Description

@altendky

Summary

normalizeMessages() in transform.ts:62-66 filters empty text parts from all message roles, including assistant. When an empty text part between two reasoning blocks with cryptographic signatures is removed, the positional context encoded in the signatures changes, and the Anthropic API rejects the replayed message with:

messages.N.content.M: `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

The filter at transform.ts:62-66 applies unconditionally to all roles:

const filtered = msg.content.filter((part) => {
  if (part.type === "text" || part.type === "reasoning") {
    return part.text !== ""
  }
  return true
})

This is correct for user and tool messages (Anthropic rejects empty content in those), but incorrect for assistant messages. Assistant messages must be replayed verbatim because thinking block signatures are positionally sensitive — they encode the exact arrangement of content blocks in the message.

How the Empty Text Gets There

processor.ts:321 calls trimEnd() on text parts, which can reduce whitespace-only text to "". With adaptive thinking (Opus 4.6, Sonnet 4.6), the model commonly emits a whitespace-only text part between two thinking blocks. After trimEnd(), this becomes "", and then normalizeMessages() removes it entirely.

Real-World Evidence

Session ses_330fc3f4dffe0Yjzl5J0topEED, message msg_ccf03eebf002tXp65stpIG2MnX:

SELECT p.id, json_extract(p.data, '$.type') as type,
       length(json_extract(p.data, '$.text')) as text_len,
       json_extract(p.data, '$.metadata.anthropic.signature') IS NOT NULL as has_sig
FROM part p WHERE p.message_id = 'msg_ccf03eebf002tXp65stpIG2MnX'
ORDER BY p.time_created;
part_id                          | type        | text_len | has_sig
---------------------------------+-------------+----------+--------
prt_ccf03f6a60015t4IDiWRnOMWwF   | step-start  |          | 0
prt_ccf03f6a8001qAKEsXpeAB2hG0   | reasoning   | 767      | 1       ← has signature
prt_ccf041564001Z6pdwv1JdBhLdP   | text        | 0        | 0       ← EMPTY, gets filtered
prt_ccf041db1001FvYkyMffHrkyBI   | reasoning   | 804      | 1       ← has signature
prt_ccf043330001rVlq9TkqyRFNcR   | text        | 144      | 0
prt_ccf043491001FyZ7q1qhNahva6   | tool        |          | 0
prt_ccf043627001gATpjAKl6bNb11   | step-finish |          | 0

The empty text part at row 3 is removed by normalizeMessages(). This changes the block arrangement from [thinking, text, thinking, text, tool_use] to [thinking, thinking, text, tool_use], invalidating the signatures.

Reproduction Test

A failing test is provided in the companion PR. It constructs:

[reasoning(sig1), text(""), reasoning(sig2), text("..."), tool-call]

Runs it through ProviderTransform.message() with an Anthropic model, and asserts all 5 parts are preserved. Currently fails — the empty text is filtered, producing only 4 parts.

expect(received).toHaveLength(expected)
Expected length: 5
Received length: 4

Existing PRs That Don't Fix This

Suggested Fix

Skip empty-text filtering for assistant messages. They must be replayed verbatim since the Anthropic API originally returned them:

const filtered = msg.content.filter((part) => {
  if (msg.role === "assistant") return true  // preserve assistant content exactly
  if (part.type === "text" || part.type === "reasoning") {
    return part.text !== ""
  }
  return true
})

Related Issues

Environment

  • Provider: Anthropic (direct API)
  • Model: claude-opus-4-6 with adaptive thinking
  • OS: Linux (Ubuntu 22.04)
  • OpenCode version: dev build (latest dev branch)

Metadata

Metadata

Assignees

Labels

coreAnything pertaining to core functionality of the application (opencode server stuff)

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