#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)

appfix
68
+444244 files

Score breakdown

Impact

9.0

Clarity

10.0

Urgency

9.0

Ease Of Review

9.0

Guidelines

9.0

Readiness

8.0

Size

1.0

Trust

8.0

Traction

2.0

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.

Open in GitHub

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:

  1. opencode's normalizeMessages() in transform.ts — unconditionally filters text === "" from all message roles, including assistant
  2. AI SDK's convertToLanguageModelPrompt — strips empty text parts from assistant messages unless providerOptions != 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 prevent cache_control on empty text blocks from applyCaching.
  • message-v2.ts (providerMetadata): Set providerMetadata: {} 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 by normalizeMessages (reasoning present), then rejected by Anthropic when applyCaching set cache_control on 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 differentModel guard so providerMetadata always passes through. If that PR lands first, the providerMetadata: {} workaround in message-v2.ts here becomes unnecessary, but the transform.ts fix is still needed since normalizeMessages() independently strips the empty text part.
  • #12131 (preserve redacted_thinking blocks) — Removes trimEnd() on thinking text in processor.ts and reorders reasoning blocks. Addresses related signature issues but does not fix normalizeMessages() 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 !== "" to text.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_control errors
    • 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 issue

Comments

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:

  1. transform.ts: The normalizeMessages filter removes text("") from assistant messages, shifting the reasoning block positions and invalidating signatures.

  2. message-v2.ts: Even if transform.ts is fixed, the AI SDK's internal convertToLanguageModelPrompt strips text parts where text === "" && providerOptions == null. The differentModel guard from Jan 20 was correct for its purpose (don't send foreign metadata), but when part.metadata is undefined for an empty text part from the same model, the resulting providerMetadata: undefined causes 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 the providerMetadata: {} fallback in message-v2.ts.

  • PR #14393 (opened 2026-02-20, still open): An earlier, broader attempt at fixing the signature stripping problem that proposed removing the differentModel guard 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

+427
@@ -50,19 +50,46 @@ export namespace ProviderTransform {
options: Record<string, unknown>,
): ModelMessage[] {
// Anthropic rejects messages with empty content - filter out empty string messages
// and remove empty text/reasoning parts from array content
// and remove empty text/reasoning parts from array content.
// Assistant messages with reasoning blocks are excluded: their content must be
// replayed verbatim because thinking block signatures encode positional context.
// Removing an empty text part between two reasoning blocks changes the block
// arrangement and invalidates the cryptographic signatures, causing the API to
// reject the request. Assistant messages without reasoning blocks are filtered
// normally since there are no signatures to preserve.
if (model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/amazon-bedrock") {
msgs = msgs
.map((msg) => {
if (msg.role === "assistant") {
if (!Array.isArray(msg.content)) return msg
if (msg.content.some((part) => part.type === "reasoning")) {
// Strip trailing empty text parts — only inter

packages/opencode/src/session/message-v2.ts

+112
@@ -673,7 +673,10 @@ export namespace MessageV2 {
msg.info.error &&
!(
MessageV2.AbortedError.isInstance(msg.info.error) &&
msg.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning")
msg.parts.some(
(part) =>
part.type !== "step-start" && part.type !== "reasoning" && !(part.type === "text" && part.text === ""),
)
)
) {
continue
@@ -688,7 +691,13 @@ export namespace MessageV2 {
assistantMessage.parts.push({
type: "text",
text: part.text,
...(differentModel ? {} : { providerMetadata: part.metadata }),
// Empty text parts between reasoning blocks must survive the AI SDK's
// internal convertToLanguageModelPrompt filter, which strips text parts
// where text==="" and providerOptions==null. Fall back to {} so the
// downstream filter sees a non-null value and preserves the part.
// Removing an empty text part shifts thinking block positions and
// invalidates cryptographic signatures.

packages/opencode/test/provider/transform.test.ts

+25715
@@ -1061,10 +1061,10 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
expect(result[1].content).toBe("World")
})
test("filters out empty text parts from array content", () => {
test("filters out empty text parts from array content in non-assistant messages", () => {
const msgs = [
{
role: "assistant",
role: "user",
content: [
{ type: "text", text: "" },
{ type: "text", text: "Hello" },
@@ -1080,7 +1080,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
expect(result[0].content[0]).toEqual({ type: "text", text: "Hello" })
})
test("filters out empty reasoning parts from array content", () => {
test("preserves all content in assistant messages including empty reasoning parts", () => {
const msgs = [
{
role: "assistant",
@@ -1094,20 +1094,20 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
const result = ProviderTransform.message(msgs, anthropicModel, {})
// Assistant messages must be replayed verbatim — no filtering
expect(result).toHaveLength(1)
ex

packages/opencode/test/session/message-v2.test.ts

+1340
@@ -644,6 +644,65 @@ describe("session.message-v2.toModelMessage", () => {
])
})
test("excludes aborted assistant messages that only have reasoning and empty text", () => {
// Reproduces the bug from session ses_3272b8b1dffe8ACUMp7xjxhFEw:
// An aborted message had [step-start, reasoning, text("")]. The empty text
// part passed the "has real content" check, causing the message to be
// included. After normalizeMessages preserved it (reasoning blocks present)
// and applyCaching set cache_control, the Anthropic API rejected with:
// "messages.5.content.1.text: cache_control cannot be set for empty text blocks"
const userID = "m-user"
const assistantID = "m-aborted"
const aborted = new MessageV2.AbortedError({
message: "The operation was aborted.",
}).toObject() as MessageV2.Assistant["error"]
const input: MessageV2.WithParts[] = [
{
info: userInfo(userID),
parts: [
{
...basePart(userID, "u1"),
type: "text",
text: "hello",
},
] as MessageV2.Part[],
},
{
info: assistantInfo(assistantID, "m-pa