#16750 · @altendky · opened Mar 9, 2026 at 1:00 PM UTC · last updated Mar 21, 2026 at 8:48 PM UTC
fix(provider): skip empty-text filtering for assistant messages in normalizeMessages (#16748)
Score breakdown
Impact
Clarity
Urgency
Ease Of Review
Guidelines
Readiness
Size
Trust
Traction
Summary
This PR fixes critical Anthropic API rejections caused by incorrect stripping of empty text parts from assistant messages, which invalidates thinking block signatures. It modifies message normalization and content handling to preserve these parts while still filtering trailing empty text.
Description
Issue for this PR
Closes #16748 Related: #13286, #16246, #15074, #10970, #14716, #6176, #9364, #8010
Type of change
- [x] Bug fix
- [ ] New feature
- [ ] Refactor / code improvement
- [ ] Documentation
What does this PR do?
Fixes Anthropic API rejections (thinking blocks in the latest assistant message cannot be modified) caused by empty text parts being stripped from assistant messages, shifting thinking block positions and invalidating cryptographic signatures.
Root cause
Two independent filters remove empty text parts from assistant messages:
- opencode's
normalizeMessages()intransform.ts— unconditionally filterstext === ""from all message roles, including assistant - AI SDK's
convertToLanguageModelPrompt— strips empty text parts from assistant messages unlessproviderOptions != null(identical behavior in both v5 and v6)
When Anthropic models use adaptive thinking (Opus 4.6, Sonnet 4.6), the model commonly emits a whitespace-only text part between reasoning blocks. After processor.ts:321 calls trimEnd(), this becomes "". Removing it changes the block arrangement (e.g., [thinking, text, thinking, text, tool_use] → [thinking, thinking, text, tool_use]), invalidating the positionally-sensitive thinking block signatures.
Changes
transform.ts: Skip empty-text filtering for assistant messages that contain reasoning blocks (signatures are positionally sensitive). Still strips trailing empty text to preventcache_controlon empty text blocks fromapplyCaching.message-v2.ts(providerMetadata): SetproviderMetadata: {}on empty text parts lacking metadata, so the AI SDK's internal filter preserves them. This uses the SDK's own escape hatch (providerOptions != null→ keep the part).message-v2.ts(aborted message check): Empty text parts no longer count as "real content" when deciding whether to keep an aborted assistant message. Previously, an aborted message with[step-start, reasoning, text("")]was kept, preserved bynormalizeMessages(reasoning present), then rejected by Anthropic whenapplyCachingsetcache_controlon the empty text.
Relationship to other open PRs
AI SDK v5→v6 migration (#13527, #15997, #12342, #13228): The AI SDK's empty-text filter in convertToLanguageModelPrompt is identical in v5 and v6 — this bug exists on both versions and the v6 migration does not address it. Those PRs only change transform.ts/message-v2.ts for type compatibility (async toModelMessages, as typeof msg.content casts, toModelOutput wrapper change) and Opus 4.6 variant definitions.
Thinking block / signature PRs:
- #14393 (preserve thinking block signatures) — Closest overlap. Removes the
differentModelguard soproviderMetadataalways passes through. If that PR lands first, theproviderMetadata: {}workaround inmessage-v2.tshere becomes unnecessary, but thetransform.tsfix is still needed sincenormalizeMessages()independently strips the empty text part. - #12131 (preserve redacted_thinking blocks) — Removes
trimEnd()on thinking text inprocessor.tsand reorders reasoning blocks. Addresses related signature issues but does not fixnormalizeMessages()stripping empty text parts between reasoning blocks. - #12634 (expand Anthropic detection and strip whitespace-only text) — Would make this bug worse: changes the filter from
text !== ""totext.trim() !== "", stripping even more content from assistant messages without distinguishing by role. - #14586 (filter empty content blocks for Bedrock) — Extends the same empty-content filter to Bedrock. Orthogonal provider — doesn't address the assistant-message signature issue.
- #11572 (strip reasoning parts for non-interleaved models) — Cross-model switching fix. Unrelated to empty-text filtering.
- #8958 (strip incompatible thinking blocks when switching to Claude) — Cross-model switching fix. Unrelated to empty-text filtering.
None of these PRs is a superset of this one. The transform.ts fix here is not covered by any other open PR.
How did you verify your code works?
- All 106 transform tests pass, 0 failures
- All message-v2 tests pass
- New tests reproduce the exact scenarios:
- Reasoning with signatures around an empty text part — verifies all 5 parts preserved
- Trailing empty text stripped to prevent
cache_controlerrors - Interstitial empty text preserved but trailing stripped in same message
- Aborted messages with only reasoning + empty text excluded from history
Checklist
- [x] I have tested my changes locally
- [x] I have not included unrelated changes in this PR
Linked Issues
#16748 normalizeMessages() removes empty text parts between reasoning blocks, invalidating Anthropic thinking block signatures
View issueComments
PR comments
altendky
History of the Modified Code
Timeline
| Date | Commit | What happened |
|---|---|---|
| 2025-11-28 | 13f89fdb8 (PR #4811) | First empty-message filter added in message-v2.ts. A community contributor (DS / @Tarquinen) added .filter((msg) => msg.parts.length > 0) before convertToModelMessages() to prevent crashing when empty messages hit the AI SDK. Only filtered at the UIMessage level — no part-level filtering. |
| 2026-01-05 | c285304a (no PR, direct commit) | Anthropic empty-content filter added in transform.ts by Aiden Cline. Anthropic's API rejects messages with empty content, so this filters out empty text and reasoning parts from array content, and removes messages that become empty. Applied to all roles (user, assistant, tool) indiscriminately. Tests all used role: "assistant" messages. |
| 2026-01-20 | 021e42c0b (no PR, direct commit) | differentModel guard added in message-v2.ts by Aiden Cline. When switching between providers, stale providerMetadata (e.g. Anthropic signatures sent to OpenAI) caused 400 errors. Fix: only include providerMetadata when the model matches. This introduced the conditional ...(differentModel ? {} : { providerMetadata: part.metadata }). |
| 2026-02-13 | 0d90a22f9 (PR #13439) | Opus 4.6 adaptive thinking enabled on vertex/bedrock/anthropic. This is when Opus 4.6 started producing interleaved reasoning blocks with cryptographic signatures, creating the conditions for the bug. |
How the bug emerged
The empty-content filter from Jan 5 (c285304a) was written before Opus 4.6 adaptive thinking existed (Feb 13). At the time, reasoning parts with empty text were genuinely useless — there were no cryptographic signatures to preserve. The filter treated all roles identically.
Once Opus 4.6 arrived, it began producing patterns like:
reasoning(sig1) → text("") → reasoning(sig2) → text("answer")
The empty text("") is structurally significant — the signatures encode positional context. There are two independent stripping points:
-
transform.ts: ThenormalizeMessagesfilter removestext("")from assistant messages, shifting the reasoning block positions and invalidating signatures. -
message-v2.ts: Even iftransform.tsis fixed, the AI SDK's internalconvertToLanguageModelPromptstrips text parts wheretext === "" && providerOptions == null. ThedifferentModelguard from Jan 20 was correct for its purpose (don't send foreign metadata), but whenpart.metadataisundefinedfor an empty text part from the same model, the resultingproviderMetadata: undefinedcauses the AI SDK to strip the part.
PRs in flight
-
PR #16750 (this branch's upstream PR, opened 2026-03-09): The fix under review. Skips empty-text filtering for assistant messages with reasoning blocks in
transform.ts, and adds theproviderMetadata: {}fallback inmessage-v2.ts. -
PR #14393 (opened 2026-02-20, still open): An earlier, broader attempt at fixing the signature stripping problem that proposed removing the
differentModelguard entirely (always pass metadata through). That PR also bundled a compaction headroom fix. It was never merged — presumably because always passing foreign provider metadata was considered too risky. The current branch's approach is more surgical: keep the guard but patch the specific empty-text-with-no-metadata case.
Summary
The code being modified was never designed for the Opus 4.6 world. The empty-content filter (c285304a, Jan 5) predates adaptive thinking by 5+ weeks and assumed empty parts are always safe to strip. The differentModel metadata guard (021e42c0b, Jan 20) was solving a real cross-provider problem but created a gap where same-model empty text parts lose their metadata lifeline. Both pieces of code were correct when written — the bug is an emergent interaction with Opus 4.6's signature-sensitive thinking blocks.
shadow-hg
谢谢你的修复,这个问题困扰了我很久。
Changed Files
packages/opencode/src/provider/transform.ts
+42−7packages/opencode/src/session/message-v2.ts
+11−2packages/opencode/test/provider/transform.test.ts
+257−15packages/opencode/test/session/message-v2.test.ts
+134−0