Skip to content

fix(provider): skip empty-text filtering for assistant messages in normalizeMessages (#16748)#16750

Open
altendky wants to merge 5 commits intoanomalyco:devfrom
altendky:test/thinking-signature-empty-text
Open

fix(provider): skip empty-text filtering for assistant messages in normalizeMessages (#16748)#16750
altendky wants to merge 5 commits intoanomalyco:devfrom
altendky:test/thinking-signature-empty-text

Conversation

@altendky
Copy link
Contributor

@altendky altendky commented Mar 9, 2026

Issue for this PR

Closes #16748
Related: #13286, #16246, #15074, #10970, #14716, #6176, #9364, #8010

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

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:

`thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified.

Fix: Skip the empty-text/reasoning filter for assistant messages. Assistant messages must be replayed verbatim because Anthropic thinking block signatures are positionally sensitive. The filter is still applied to user and tool messages where Anthropic rejects empty content.

Changes:

  • transform.ts: Add if (msg.role === "assistant") return msg before the filter. Also simplified the remaining filter to only check text parts (since reasoning parts only appear in assistant messages, which are now skipped).
  • transform.test.ts: Added a failing test reproducing the exact scenario (reasoning with signatures around an empty text part). Updated 5 existing tests whose expectations assumed filtering applied to assistant messages.

How did you verify your code works?

  • All 106 transform tests pass, 0 failures
  • The new test constructs [reasoning(sig1), text(""), reasoning(sig2), text("..."), tool-call], runs through ProviderTransform.message() with an Anthropic model, and asserts all 5 parts are preserved
  • Before the fix: Expected length: 5, Received length: 4
  • After the fix: passes

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

…es (anomalyco#16748)

Add failing test demonstrating that normalizeMessages() removes empty text
parts between reasoning blocks in assistant messages, invalidating Anthropic
thinking block signatures. The test constructs [reasoning(sig1), text(''),
reasoning(sig2), text('...'), tool-call] and asserts all 5 parts are preserved.
Currently fails with Expected length: 5, Received length: 4.
@github-actions github-actions bot added needs:compliance This means the issue will auto-close after 2 hours. needs:issue labels Mar 9, 2026
@github-actions
Copy link
Contributor

github-actions bot commented Mar 9, 2026

Thanks for your contribution!

This PR doesn't have a linked issue. All PRs must reference an existing issue.

Please:

  1. Open an issue describing the bug/feature (if one doesn't exist)
  2. Add Fixes #<number> or Closes #<number> to this PR description

See CONTRIBUTING.md for details.

@github-actions
Copy link
Contributor

github-actions bot commented Mar 9, 2026

The following comment was made by an LLM, it may be inaccurate:

Based on my search results, I found related PRs addressing thinking block and empty content filtering issues, but these appear to be related fixes rather than duplicates of the current test reproduction PR (#16750):

Related PRs:

The current PR #16750 is a test reproduction case that demonstrates the issue described in #16748, whereas the related PRs are attempted fixes or related provider handling. No exact duplicate reproduction test was found.

@altendky altendky marked this pull request as draft March 9, 2026 13:02
…rmalizeMessages

Assistant messages must be replayed verbatim because Anthropic 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 with 'thinking blocks cannot be modified'.

The empty-text filter is still applied to user and tool messages where Anthropic
rejects empty content.

Update existing tests to reflect that assistant content is now preserved, and
add tests for non-assistant filtering.
@altendky altendky changed the title test: reproduce empty text filtering breaking thinking block signatures (#16748) fix(provider): skip empty-text filtering for assistant messages in normalizeMessages (#16748) Mar 9, 2026
@github-actions github-actions bot removed needs:issue needs:compliance This means the issue will auto-close after 2 hours. labels Mar 9, 2026
@github-actions
Copy link
Contributor

github-actions bot commented Mar 9, 2026

Thanks for updating your PR! It now meets our contributing guidelines. 👍

@altendky altendky marked this pull request as ready for review March 9, 2026 13:12
altendky added 3 commits March 9, 2026 09:16
The previous fix preserved all assistant messages verbatim in normalizeMessages
to protect thinking block signatures. This was too broad — assistant messages
without reasoning blocks (e.g. compaction summaries) also had empty text parts
preserved, causing Anthropic to reject with 'text content blocks must be non-empty'.

Now only assistant messages with reasoning blocks are preserved verbatim.
Assistant messages without reasoning have empty text blocks filtered normally.

Also clean up empty text parts at the source: in the processor text-end handler,
remove parts that end up empty (no text, no metadata) instead of persisting them.
Parts with metadata (thinking signatures) are still preserved.
…elete race

The text-start handler persists an empty part to the DB, then text-end
would conditionally delete it. If the process crashes between the two,
a dangling empty part remains. The transform.ts and message-v2.ts
replay-time defenses already handle empty text parts correctly, making
the processor-level cleanup redundant.
@altendky
Copy link
Contributor Author

altendky commented Mar 9, 2026

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

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant