Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions packages/ai/src/generate-text/generate-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8835,4 +8835,100 @@
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<Parameters<
ConstructorParameters<typeof MockLanguageModelV4>[0] & {

Check failure on line 8843 in packages/ai/src/generate-text/generate-text.test.ts

View workflow job for this annotation

GitHub Actions / TypeScript

Type '{ provider?: string | undefined; modelId?: string | undefined; supportedUrls?: Record<string, RegExp[]> | PromiseLike<Record<string, RegExp[]>> | (() => Record<...> | PromiseLike<...>) | undefined; doGenerate?: ((options: LanguageModelV4CallOptions) => PromiseLike<...>) | ... 2 more ... | undefined; doStream?: ((opt...' does not satisfy the constraint '(...args: any) => any'.
doGenerate: (...args: any[]) => any;
}
>> = [];

const result = await generateText({
model: new MockLanguageModelV4({
doGenerate: async options => {
doGenerateCalls.push(options as any);

Check failure on line 8851 in packages/ai/src/generate-text/generate-text.test.ts

View workflow job for this annotation

GitHub Actions / TypeScript

Argument of type 'any' is not assignable to parameter of type 'never'.
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;

Check failure on line 8910 in packages/ai/src/generate-text/generate-text.test.ts

View workflow job for this annotation

GitHub Actions / TypeScript

Property 'prompt' does not exist on type 'never'.
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',
]);
});
});
});
94 changes: 93 additions & 1 deletion packages/ai/src/generate-text/to-response-messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand All @@ -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,
Expand Down
5 changes: 0 additions & 5 deletions packages/ai/src/generate-text/to-response-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,6 @@ export async function toResponseMessages<TOOLS extends ToolSet>({
continue;
}

// Skip empty text
if (part.type === 'text' && part.text.length === 0) {
continue;
}

switch (part.type) {
case 'text':
content.push({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -1250,6 +1250,10 @@ describe('convertToLanguageModelMessage', () => {
expect(result).toEqual({
role: 'assistant',
content: [
{
type: 'text',
text: '',
},
{
type: 'tool-call',
input: {},
Expand Down
7 changes: 0 additions & 7 deletions packages/ai/src/prompt/convert-to-language-model-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading