diff --git a/.changeset/fix-convert-to-model-messages-empty-assistant.md b/.changeset/fix-convert-to-model-messages-empty-assistant.md new file mode 100644 index 000000000000..42c7e888bea3 --- /dev/null +++ b/.changeset/fix-convert-to-model-messages-empty-assistant.md @@ -0,0 +1,5 @@ +--- +'ai': patch +--- + +Fix: `convertToModelMessages` no longer emits an empty assistant message when a block contains only unknown data parts (e.g. a data part before `step-start` with no `convertDataPart` provided) diff --git a/packages/ai/src/ui/convert-to-model-messages.test.ts b/packages/ai/src/ui/convert-to-model-messages.test.ts index 75a2d29d90aa..47aa25bca287 100644 --- a/packages/ai/src/ui/convert-to-model-messages.test.ts +++ b/packages/ai/src/ui/convert-to-model-messages.test.ts @@ -1,8 +1,41 @@ +import { convertArrayToReadableStream } from '@ai-sdk/provider-utils/test'; import type { ModelMessage } from '@ai-sdk/provider-utils'; import { describe, expect, it } from 'vitest'; +import type { UIMessageChunk } from '../ui-message-stream/ui-message-chunks'; +import { consumeStream } from '../util/consume-stream'; import { convertToModelMessages } from './convert-to-model-messages'; +import { + createStreamingUIMessageState, + processUIMessageStream, +} from './process-ui-message-stream'; import type { UIMessage } from './ui-messages'; +async function recordAssistantMessageFromChunks< + UI_MESSAGE extends UIMessage = UIMessage, +>(chunks: UIMessageChunk[]): Promise { + const state = createStreamingUIMessageState({ + messageId: 'msg-123', + lastMessage: undefined, + }); + + await consumeStream({ + stream: processUIMessageStream({ + stream: convertArrayToReadableStream(chunks), + runUpdateMessageJob: async job => { + await job({ + state, + write: () => {}, + }); + }, + onError: error => { + throw error; + }, + }), + }); + + return state.message; +} + describe('convertToModelMessages', () => { describe('system message', () => { it('should convert a simple system message', () => { @@ -1875,8 +1908,85 @@ describe('convertToModelMessages', () => { `); }); +<<<<<<< HEAD it('should selectively convert data parts', () => { const result = convertToModelMessages< +======= + it('should not emit empty assistant message for persistent data written before model stream starts', async () => { + type WeatherUIMessage = UIMessage< + unknown, + { + weather: { + city: string; + status: 'loading' | 'success'; + weather?: string; + }; + } + >; + + const recordedMessage = + await recordAssistantMessageFromChunks([ + { type: 'start', messageId: 'msg-123' }, + { + type: 'data-weather', + id: 'weather-1', + data: { city: 'San Francisco', status: 'loading' }, + }, + { type: 'start-step' }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'It is sunny.' }, + { type: 'text-end', id: 'text-1' }, + { type: 'finish-step' }, + { type: 'finish' }, + ]); + + expect(recordedMessage).toMatchInlineSnapshot(` + { + "id": "msg-123", + "metadata": undefined, + "parts": [ + { + "data": { + "city": "San Francisco", + "status": "loading", + }, + "id": "weather-1", + "type": "data-weather", + }, + { + "type": "step-start", + }, + { + "providerMetadata": undefined, + "state": "done", + "text": "It is sunny.", + "type": "text", + }, + ], + "role": "assistant", + } + `); + + const result = await convertToModelMessages([recordedMessage]); + + expect(result).toMatchInlineSnapshot(` + [ + { + "content": [ + { + "text": "It is sunny.", + "type": "text", + }, + ], + "role": "assistant", + }, + ] + `); + }); + + it('should selectively convert data parts', async () => { + const result = await convertToModelMessages< +>>>>>>> aa2dbe65e8 ([v6.0] fix(ai): omit empty assistant message for data-only blocks in convertToModelMessages (#16793)) UIMessage< unknown, { diff --git a/packages/ai/src/ui/convert-to-model-messages.ts b/packages/ai/src/ui/convert-to-model-messages.ts index 36a6f0587408..6fade07be9e9 100644 --- a/packages/ai/src/ui/convert-to-model-messages.ts +++ b/packages/ai/src/ui/convert-to-model-messages.ts @@ -240,10 +240,12 @@ export function convertToModelMessages( } } - modelMessages.push({ - role: 'assistant', - content, - }); + if (content.length > 0) { + modelMessages.push({ + role: 'assistant', + content, + }); + } // check if there are tool invocations with results in the block const toolParts = block.filter(