-
Notifications
You must be signed in to change notification settings - Fork 15.4k
normalizeMessages() removes empty text parts between reasoning blocks, invalidating Anthropic thinking block signatures #16748
Description
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
- fix: preserve thinking block signatures and fix compaction headroom asymmetry #14393 (open) — fixes the
differentModelguard that stripsproviderMetadataduring compaction. Different trigger, doesn't addressnormalizeMessagesfiltering. - fix(provider): preserve redacted_thinking blocks and fix signature validation #12131 (open) — fixes
trimEnd()on thinking text and reorders reasoning blocks first. Different trigger, doesn't address the empty-text filter. - fix: expand Anthropic detection and strip whitespace-only text blocks #12634 (open) — would make this worse by expanding the filter from
=== ""to.trim() === "", increasing the surface area for signature corruption.
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
- Claude Opus 4.5 (latest) eventually fails: thinking block cannot be modified #13286 — Claude Opus 4.5 (latest) eventually fails: thinking block cannot be modified
- Anthropic subagents fail with reasoning.effort due to thinking signature replay in multi-turn sessions #16246 — Anthropic subagents fail with reasoning.effort due to thinking signature replay
- Bad Request: Invalid signature in thinking block when calling jiekou/claude-opus-4.6 #15074 — Bad Request: Invalid signature in thinking block
- Claude Models Thinking block error #10970 — Claude Models Thinking block error
- Using reasoning models with OpenRouter provider yields "provider error" due rewritten reasoning blocks. #14716 — OpenRouter reasoning models yield "provider error" due rewritten reasoning blocks
- Claude extended thinking + tool use: signature not preserved in message history #6176 — Claude extended thinking + tool use: signature not preserved in message history
- Claude Extended Thinking: assistant message content order causes API error #9364 — Claude Extended Thinking: assistant message content order causes API error
- Expected
thinkingorredacted_thinkingbut foundtool_use#8010 — Expectedthinkingorredacted_thinkingbut foundtool_use
Environment
- Provider: Anthropic (direct API)
- Model: claude-opus-4-6 with adaptive thinking
- OS: Linux (Ubuntu 22.04)
- OpenCode version: dev build (latest
devbranch)