Skip to content

[v6.0] fix(ai): omit empty assistant message for data-only blocks in convertToModelMessages#16793

Merged
gr2m merged 1 commit into
release-v6.0from
backport-v6.0/fix-15040
Jul 6, 2026
Merged

[v6.0] fix(ai): omit empty assistant message for data-only blocks in convertToModelMessages#16793
gr2m merged 1 commit into
release-v6.0from
backport-v6.0/fix-15040

Conversation

@gr2m

@gr2m gr2m commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Backport of #15040 to release-v6.0 (clean cherry-pick). Data-only blocks previously produced empty assistant messages.

Part of the v6.0 backport tracking issue #16767.

…ToModelMessages (#15040)

## Background

`convertToModelMessages` was emitting `{ role: 'assistant', content: []
}` when a block contained only unknown data parts (i.e. a data part
appears before `step-start` with no `convertDataPart` provided, or
`convertDataPart` returns `null`). LLMs reject empty assistant messages,
causing downstream failures.

## Summary

- Guard `content.length > 0` before pushing the assistant message in
`processBlock()` in `convert-to-model-messages.ts`
- Add regression test for the data-part-before-step-start scenario

## Manual Verification

Verified that this can happen through the public UI stream helpers with
`streamText`, not only from a hand-written message fixture.

The real route pattern from
`content/docs/04-ai-sdk-ui/20-streaming-data.mdx` writes a persistent
data part before merging the model stream:

```ts
writer.write({
  type: 'data-weather',
  id: 'weather-1',
  data: { city: 'San Francisco', status: 'loading' },
});

const result = streamText({
  model,
  prompt: 'What is the weather in San Francisco?',
});

writer.merge(result.toUIMessageStream());
```

That ordering is valid because `createUIMessageStream` enqueues
`writer.write()` immediately, while `writer.merge()` forwards chunks
from the merged stream later. The merged `streamText` UI stream is what
emits `start-step`.

I reproduced the route shape end-to-end with `streamText` and
`MockLanguageModelV4`:

```ts
function createWeatherModel() {
  return new MockLanguageModelV4({
    doStream: async () => ({
      stream: convertArrayToReadableStream([
        { type: 'stream-start', warnings: [] },
        {
          type: 'response-metadata',
          id: 'response-1',
          modelId: 'mock-model-id',
          timestamp: new Date(0),
        },
        { type: 'text-start', id: 'text-1' },
        { type: 'text-delta', id: 'text-1', delta: 'It is sunny.' },
        { type: 'text-end', id: 'text-1' },
        {
          type: 'finish',
          finishReason: { unified: 'stop', raw: 'stop' },
          usage: {
            inputTokens: {
              total: 3,
              noCache: 3,
              cacheRead: undefined,
              cacheWrite: undefined,
            },
            outputTokens: {
              total: 4,
              text: 4,
              reasoning: undefined,
            },
          },
        },
      ]),
    }),
  });
}
```

The test route writes the data part first, then merges
`streamText(...).toUIMessageStream()`:

```ts
const chunks = await convertReadableStreamToArray(
  createUIMessageStream<WeatherUIMessage>({
    generateId: () => 'msg-123',
    onEnd({ responseMessage }) {
      persistedMessage = responseMessage;
    },
    execute({ writer }) {
      writer.write({
        type: 'data-weather',
        id: 'weather-1',
        data: { city: 'San Francisco', status: 'loading' },
      });

      const result = streamText({
        model: createWeatherModel(),
        prompt: 'What is the weather in San Francisco?',
      });

      writer.merge(result.toUIMessageStream());
    },
  }),
);
```

The recorded UI chunk order was:

```ts
[
  'data-weather',
  'start',
  'start-step',
  'text-start',
  'text-delta',
  'text-end',
  'finish-step',
  'finish',
]
```

The persisted assistant message had `data-weather` before `step-start`:

```ts
[
  {
    type: 'data-weather',
    id: 'weather-1',
    data: { city: 'San Francisco', status: 'loading' },
  },
  { type: 'step-start' },
  {
    type: 'text',
    text: 'It is sunny.',
    state: 'done',
    providerMetadata: undefined,
  },
]
```

Against the test-only commit (`6fa891d8b6`), the repro failed because
`convertToModelMessages([persistedMessage])` returned an extra empty
assistant message:

```ts
[
  { role: 'assistant', content: [] },
  { role: 'assistant', content: [{ type: 'text', text: 'It is sunny.' }] },
]
```

Against the fixed commit (`872f91b540`), the same repro passed and
returned only the valid assistant message:

```ts
[
  { role: 'assistant', content: [{ type: 'text', text: 'It is sunny.' }] },
]
```

Focused test on the fixed head:

```sh
vitest --config packages/ai/vitest.node.config.js --run packages/ai/src/ui/convert-to-model-messages.test.ts
```

Result: 64 tests passed, no type errors.

## Checklist
- [x] All commits are signed
- [x] Tests have been added / updated
- [ ] Documentation has been added / updated
- [x] A _patch_ changeset for relevant packages has been added
- [x] I have reviewed this pull request

## Related Issues
Fixes #13513

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Gregor Martynus <39992+gr2m@users.noreply.github.com>
(cherry picked from commit d598481)
@gr2m gr2m added the backport Admins only: add this label to a pull request in order to backport it to the prior version label Jul 6, 2026
@gr2m gr2m merged commit aa2dbe6 into release-v6.0 Jul 6, 2026
52 checks passed
@gr2m gr2m deleted the backport-v6.0/fix-15040 branch July 6, 2026 20:06
github-actions Bot added a commit that referenced this pull request Jul 6, 2026
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

⚠️ Backport to release-v5.0 created but has conflicts: #16821

@github-actions github-actions Bot removed the backport Admins only: add this label to a pull request in order to backport it to the prior version label Jul 6, 2026
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

🚀 Published in:

Package Version
ai 6.0.221 github npm
@ai-sdk/alibaba 1.0.36 github npm
@ai-sdk/amazon-bedrock 4.0.131 github npm
@ai-sdk/angular 2.0.222 github npm
@ai-sdk/anthropic 3.0.95 github npm
@ai-sdk/anthropic-aws 1.0.17 github npm
@ai-sdk/assemblyai 2.0.44 github npm
@ai-sdk/azure 3.0.86 github npm
@ai-sdk/baseten 1.0.61 github npm
@ai-sdk/black-forest-labs 1.0.45 github npm
@ai-sdk/bytedance 1.0.25 github npm
@ai-sdk/cerebras 2.0.64 github npm
@ai-sdk/cohere 3.0.46 github npm
@ai-sdk/deepgram 2.0.43 github npm
@ai-sdk/deepinfra 2.0.62 github npm
@ai-sdk/deepseek 2.0.46 github npm
@ai-sdk/elevenlabs 2.0.43 github npm
@ai-sdk/fal 2.0.44 github npm
@ai-sdk/fireworks 2.0.66 github npm
@ai-sdk/gateway 3.0.145 github npm
@ai-sdk/gladia 2.0.43 github npm
@ai-sdk/google 3.0.90 github npm
@ai-sdk/google-vertex 4.0.158 github npm
@ai-sdk/groq 3.0.49 github npm
@ai-sdk/huggingface 1.0.60 github npm
@ai-sdk/hume 2.0.43 github npm
@ai-sdk/klingai 3.0.28 github npm
@ai-sdk/langchain 2.0.229 github npm
@ai-sdk/llamaindex 2.0.221 github npm
@ai-sdk/lmnt 2.0.43 github npm
@ai-sdk/luma 2.0.43 github npm
@ai-sdk/mcp 1.0.60 github npm
@ai-sdk/mistral 3.0.47 github npm
@ai-sdk/moonshotai 2.0.34 github npm
@ai-sdk/open-responses 1.0.26 github npm
@ai-sdk/openai 3.0.82 github npm
@ai-sdk/openai-compatible 2.0.58 github npm
@ai-sdk/perplexity 3.0.44 github npm
@ai-sdk/prodia 1.0.42 github npm
@ai-sdk/provider-utils 4.0.37 github npm
@ai-sdk/quiverai 1.0.10 github npm
@ai-sdk/react 3.0.223 github npm
@ai-sdk/replicate 2.0.43 github npm
@ai-sdk/revai 2.0.43 github npm
@ai-sdk/rsc 2.0.221 github npm
@ai-sdk/svelte 4.0.221 github npm
@ai-sdk/togetherai 2.0.63 github npm
@ai-sdk/valibot 2.0.38 github npm
@ai-sdk/vercel 2.0.60 github npm
@ai-sdk/voyage 1.0.14 github npm
@ai-sdk/vue 3.0.221 github npm
@ai-sdk/xai 3.0.104 github npm

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants