diff --git a/packages/ai/src/generate-text/generate-text.test.ts b/packages/ai/src/generate-text/generate-text.test.ts index 042631852e0d..7c284ee4b3e2 100644 --- a/packages/ai/src/generate-text/generate-text.test.ts +++ b/packages/ai/src/generate-text/generate-text.test.ts @@ -8835,4 +8835,100 @@ describe('generateText', () => { expect(events).toEqual(['first', 'second']); }); }); + + describe('empty text blocks between reasoning blocks', () => { + it('should preserve empty text blocks in step 2 messages when reasoning blocks are present', async () => { + let responseCount = 0; + const doGenerateCalls: Array[0] & { + doGenerate: (...args: any[]) => any; + } + >> = []; + + const result = await generateText({ + model: new MockLanguageModelV4({ + doGenerate: async options => { + doGenerateCalls.push(options as any); + switch (responseCount++) { + case 0: + return { + ...dummyResponseValues, + content: [ + { + type: 'reasoning' as const, + text: 'First reasoning block', + signature: 'sig-1', + providerMetadata: { + bedrock: { signature: 'sig-1' }, + }, + }, + { type: 'text' as const, text: '' }, + { + type: 'reasoning' as const, + text: 'Second reasoning block', + signature: 'sig-2', + providerMetadata: { + bedrock: { signature: 'sig-2' }, + }, + }, + { type: 'text' as const, text: 'Visible response' }, + { + type: 'tool-call' as const, + toolCallType: 'function' as const, + toolCallId: 'call-1', + toolName: 'myTool', + input: '{ "value": "test" }', + }, + ], + finishReason: { + unified: 'tool-calls' as const, + raw: undefined, + }, + }; + default: + return { + ...dummyResponseValues, + content: [{ type: 'text' as const, text: 'Done.' }], + }; + } + }, + }), + tools: { + myTool: tool({ + inputSchema: z.object({ value: z.string() }), + execute: async ({ value }) => `${value}-result`, + }), + }, + prompt: 'test-input', + stopWhen: isStepCount(3), + }); + + // Step 2 should have been called + expect(doGenerateCalls.length).toBe(2); + + // Get the assistant message from step 2's prompt + const step2Prompt = doGenerateCalls[1].prompt; + const assistantMessage = step2Prompt.find( + (m: any) => m.role === 'assistant', + ); + + expect(assistantMessage).toBeDefined(); + + // The assistant message content must preserve the empty text block + // between reasoning blocks. Without this, providers like Bedrock + // reject with "thinking blocks cannot be modified" because the + // content block indices shift. + const contentTypes = assistantMessage!.content.map( + (p: any) => p.type + (p.text === '' ? '(empty)' : ''), + ); + + expect(contentTypes).toEqual([ + 'reasoning', + 'text(empty)', + 'reasoning', + 'text', + 'tool-call', + ]); + }); + }); }); diff --git a/packages/ai/src/generate-text/to-response-messages.test.ts b/packages/ai/src/generate-text/to-response-messages.test.ts index a92edfb5cf5f..cffad136c0be 100644 --- a/packages/ai/src/generate-text/to-response-messages.test.ts +++ b/packages/ai/src/generate-text/to-response-messages.test.ts @@ -703,7 +703,7 @@ describe('toResponseMessages', () => { `); }); - it('should not append text parts if text is empty string', async () => { + it('should preserve empty text parts', async () => { const result = await toResponseMessages({ content: [ { @@ -729,6 +729,98 @@ describe('toResponseMessages', () => { [ { "content": [ + { + "providerOptions": undefined, + "text": "", + "type": "text", + }, + { + "input": {}, + "providerExecuted": undefined, + "providerOptions": undefined, + "toolCallId": "123", + "toolName": "testTool", + "type": "tool-call", + }, + ], + "role": "assistant", + }, + ] + `); + }); + + it('should preserve empty text between reasoning blocks for structural integrity', async () => { + const result = await toResponseMessages({ + content: [ + { + type: 'reasoning', + text: 'First reasoning block', + providerMetadata: { + bedrock: { signature: 'sig-1' }, + }, + }, + { + type: 'text', + text: '', + }, + { + type: 'reasoning', + text: 'Second reasoning block', + providerMetadata: { + bedrock: { signature: 'sig-2' }, + }, + }, + { + type: 'text', + text: 'Final response', + }, + { + type: 'tool-call', + toolCallId: '123', + toolName: 'testTool', + input: {}, + }, + ], + tools: { + testTool: tool({ + description: 'A test tool', + inputSchema: z.object({}), + }), + }, + }); + + expect(result).toMatchInlineSnapshot(` + [ + { + "content": [ + { + "providerOptions": { + "bedrock": { + "signature": "sig-1", + }, + }, + "text": "First reasoning block", + "type": "reasoning", + }, + { + "providerOptions": undefined, + "text": "", + "type": "text", + }, + { + "providerOptions": { + "bedrock": { + "signature": "sig-2", + }, + }, + "text": "Second reasoning block", + "type": "reasoning", + }, + { + "providerOptions": undefined, + "text": "Final response", + "type": "text", + }, { "input": {}, "providerExecuted": undefined, diff --git a/packages/ai/src/generate-text/to-response-messages.ts b/packages/ai/src/generate-text/to-response-messages.ts index 40f803ebe2c1..9b9185c5bdb2 100644 --- a/packages/ai/src/generate-text/to-response-messages.ts +++ b/packages/ai/src/generate-text/to-response-messages.ts @@ -35,11 +35,6 @@ export async function toResponseMessages({ continue; } - // Skip empty text - if (part.type === 'text' && part.text.length === 0) { - continue; - } - switch (part.type) { case 'text': content.push({ diff --git a/packages/ai/src/prompt/convert-to-language-model-prompt.test.ts b/packages/ai/src/prompt/convert-to-language-model-prompt.test.ts index d132d49e5333..1f188ce5e35a 100644 --- a/packages/ai/src/prompt/convert-to-language-model-prompt.test.ts +++ b/packages/ai/src/prompt/convert-to-language-model-prompt.test.ts @@ -1227,7 +1227,7 @@ describe('convertToLanguageModelMessage', () => { }); describe('text parts', () => { - it('should ignore empty text parts when there are no provider options', async () => { + it('should preserve empty text parts in assistant messages', async () => { const result = convertToLanguageModelMessage({ message: { role: 'assistant', @@ -1250,6 +1250,10 @@ describe('convertToLanguageModelMessage', () => { expect(result).toEqual({ role: 'assistant', content: [ + { + type: 'text', + text: '', + }, { type: 'tool-call', input: {}, diff --git a/packages/ai/src/prompt/convert-to-language-model-prompt.ts b/packages/ai/src/prompt/convert-to-language-model-prompt.ts index 92d5de7cb5eb..53da1f0987c8 100644 --- a/packages/ai/src/prompt/convert-to-language-model-prompt.ts +++ b/packages/ai/src/prompt/convert-to-language-model-prompt.ts @@ -223,13 +223,6 @@ export function convertToLanguageModelMessage({ return { role: 'assistant', content: message.content - .filter( - // remove empty text parts (no text, and no provider options): - part => - part.type !== 'text' || - part.text !== '' || - part.providerOptions != null, - ) .filter( ( part,