fix(ai): preserve empty text parts in multi-step response messages#14070
fix(ai): preserve empty text parts in multi-step response messages#14070nelsonauner wants to merge 3 commits intovercel:mainfrom
Conversation
When Bedrock/Claude returns an empty text block between reasoning blocks, toResponseMessages() was filtering it out. This shifted all subsequent content block indices, causing Bedrock to reject step 2 with "thinking blocks cannot be modified" because the block structure no longer matched the original response. The fix removes the empty text filter. Empty text parts are structurally significant when reasoning/thinking blocks are present, as providers validate that the content block structure is unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…rompt A second filter in convert-to-language-model-prompt.ts was also stripping empty text blocks from assistant messages. This filter must be removed for the same reason: empty text blocks are structurally significant when reasoning/thinking blocks are present. Also adds an end-to-end reproduction test in generate-text.test.ts that verifies empty text blocks between reasoning blocks survive the full multi-step round-trip. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
I ran this example 100 times in an attempt to reproduce the problem, but no luck so far import { bedrock } from '@ai-sdk/amazon-bedrock';
import { isStepCount, streamText, tool } from 'ai';
import { z } from 'zod';
import { run } from '../../lib/run';
import { saveRawChunks } from '../../lib/save-raw-chunks';
// When Bedrock Claude with thinking enabled returns an empty text block
// between reasoning blocks (e.g. [reasoning, "", reasoning, text, tool_call]),
// toResponseMessages() filters it out. On the next step, Bedrock rejects the
// request because the content structure no longer matches the original response.
//
// The bug is intermittent — Claude only sometimes emits empty text blocks
// between reasoning blocks. Run this example multiple times to reproduce.
run(async () => {
const result = streamText({
model: bedrock('us.anthropic.claude-sonnet-4-5-20250929-v1:0'),
stopWhen: isStepCount(5),
reasoning: 'low',
includeRawChunks: true,
tools: {
weather: tool({
description: 'Get the current weather in a location',
inputSchema: z.object({
location: z.string().describe('The location to get the weather for'),
}),
execute: async ({ location }) => ({
location,
temperature: 72 + Math.floor(Math.random() * 21) - 10,
condition: 'partly cloudy',
}),
}),
search: tool({
description: 'Search for information on the web',
inputSchema: z.object({
query: z.string().describe('The search query'),
}),
execute: async ({ query }) => ({
results: [
{
title: `Result for: ${query}`,
snippet: 'This is a mock search result.',
},
],
}),
}),
},
prompt:
'What is the weather in San Francisco and New York? Also search for "best restaurants in San Francisco". Think carefully about how to approach this.',
});
await saveRawChunks({
result,
filename: 'bedrock-thinking-tool-call',
});
});Anything we could do to adapt the example to make it closer to your own AI SDK use? |
Hard to say - we've observed this 13 times out of millions of requests so I think repro'ing might be damn near impossible. Let me talk with the bedrock team |
|
Me general point though (discussed in Slack) is it seems very odd to be dropping messages that are returned by the LLM provider. It messes up the whole ordering (e.g. grabbing by index) of the conversation/messages. |
|
Here is the response from the Bedrock team asserting that empty text outputs are valid: |
|
a few notes: a) the filtering was introduced in a time when there were no content parts, just text, to prevent unnecessary ui updates (see https://github.com/vercel/ai/pull/2966/changes ). this is less relevant now that there are content parts. if a provider roundtrip breaks because of the filtering, it needs to be removed. |
|
Also relevant, Anthropic breaks the other way around
@nelsonauner for now, can we try to contain this fix to bedrock? I think if we add For the upcoming v7 we will look into removing the current behavior of removing empty text chunks by default, instead implement workarounds where needed (like the one for anthropic) |
|
This was merged in via #14175, closing this PR |
Summary
toResponseMessages()was filtering out empty text blocks (text: ""), which broke multi-stepgenerateTextcalls when Bedrock/Claude returned an empty text block between reasoning/thinking blocks."thinking blocks cannot be modified. These blocks must remain as they were in the original response."Root cause
Bedrock Converse API response (step 1):
After
toResponseMessages()filtered the empty text, step 2 sent:Changes
to-response-messages.ts— Removed the empty text filter. Empty text parts are structurally significant and must be preserved for round-tripping.to-response-messages.test.ts— Updated existing test, added new test reproducing the exact production scenario (reasoning + empty text + reasoning + tool-call).Test plan
to-response-messages.test.ts— 21 tests passgenerate-text.test.ts— 184 tests pass🤖 Generated with Claude Code