Skip to content

Commit f05a3a5

Browse files
roaminroclaude
andauthored
fix(core): filter data-* parts before model message conversion (#12373)
Fixes #12363 After using `writer.custom()` in a tool, subsequent messages were failing with Gemini: ``` Unable to submit request because it must include at least one parts field ``` The issue was that when messages contained only `data-*` parts (custom streaming data), the AI SDK's `convertToModelMessages` would produce messages with empty content arrays - which Gemini rejects. The fix filters out `data-*` parts in `sanitizeV5UIMessages` before conversion. Messages with only data-* parts are now removed from the LLM prompt entirely, while still preserved in DB/UI for frontend rendering. ```typescript // Tool using writer.custom() now works correctly with Gemini execute: async (inputData, { writer }) => { await writer?.custom({ type: "data-chart", data: inputData, }); return { success: true }; } ``` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Bug Fixes * Fixed an issue where custom data parts in messages caused Gemini to fail when messages contained only data-related content. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 2b0936b commit f05a3a5

File tree

3 files changed

+366
-1
lines changed

3 files changed

+366
-1
lines changed

.changeset/upset-lizards-travel.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@mastra/core': patch
3+
---
4+
5+
Fixed custom data parts from writer.custom() breaking subsequent messages with Gemini. Messages containing only data-\* parts no longer produce empty content arrays that cause Gemini to fail with 'must include at least one parts field'.

packages/core/src/agent/message-list/conversion/output-converter.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export function sanitizeAIV4UIMessages(messages: UIMessageV4[]): UIMessageV4[] {
4545
}
4646

4747
/**
48-
* Sanitizes AIV5 UI messages by filtering out streaming states and optionally incomplete tool calls.
48+
* Sanitizes AIV5 UI messages by filtering out streaming states, data-* parts, and optionally incomplete tool calls.
4949
*/
5050
export function sanitizeV5UIMessages(
5151
messages: AIV5Type.UIMessage[],
@@ -56,6 +56,14 @@ export function sanitizeV5UIMessages(
5656
if (m.parts.length === 0) return false;
5757
// Filter out streaming states and optionally input-available (which aren't supported by convertToModelMessages)
5858
const safeParts = m.parts.filter(p => {
59+
// Filter out data-* parts (custom streaming data from writer.custom())
60+
// These are Mastra extensions not supported by LLM providers.
61+
// If not filtered, convertToModelMessages produces empty content arrays
62+
// which causes some models to fail with "must include at least one parts field"
63+
if (typeof p.type === 'string' && p.type.startsWith('data-')) {
64+
return false;
65+
}
66+
5967
if (!AIV5.isToolUIPart(p)) return true;
6068

6169
// When sending messages TO the LLM: only keep completed tool calls (output-available/output-error)

packages/core/src/agent/message-list/tests/message-list-gemini.test.ts

Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { convertToModelMessages } from '@internal/ai-sdk-v5';
12
import { describe, expect, it } from 'vitest';
3+
import type { MastraDBMessage } from '../index';
24
import { MessageList } from '../index';
35

46
describe('MessageList - Gemini Compatibility', () => {
@@ -162,6 +164,356 @@ describe('MessageList - Gemini Compatibility', () => {
162164
});
163165
});
164166

167+
describe('data-* parts filtering - Issue #12363', () => {
168+
// After using writer.custom() in a tool, custom data parts get stored in conversation history.
169+
// When sending messages to Gemini, these data-* parts must be filtered out because Gemini
170+
// doesn't recognize them and will fail with:
171+
// "Unable to submit request because it must include at least one parts field"
172+
173+
it('should filter out data-* parts from aiV5.prompt() for Gemini compatibility', () => {
174+
const list = new MessageList();
175+
176+
list.add({ role: 'user', content: 'Create a chart for me' }, 'input');
177+
178+
// Simulate assistant response with custom data parts from writer.custom()
179+
const assistantWithDataParts: MastraDBMessage = {
180+
id: 'msg-with-data',
181+
role: 'assistant',
182+
createdAt: new Date(),
183+
content: {
184+
format: 2,
185+
parts: [
186+
{ type: 'text', text: 'Here is your chart:' },
187+
{
188+
type: 'data-chart',
189+
data: {
190+
chartType: 'bar',
191+
values: [10, 20, 30],
192+
},
193+
} as any,
194+
],
195+
content: 'Here is your chart:',
196+
},
197+
};
198+
199+
list.add(assistantWithDataParts, 'response');
200+
list.add({ role: 'user', content: 'Can you modify the chart?' }, 'input');
201+
202+
const prompt = list.get.all.aiV5.prompt();
203+
204+
// Find the assistant message in the prompt
205+
const assistantMsg = prompt.find(m => m.role === 'assistant');
206+
expect(assistantMsg).toBeDefined();
207+
208+
// The assistant message content should NOT contain data-* parts
209+
if (typeof assistantMsg!.content !== 'string') {
210+
const hasDataPart = assistantMsg!.content.some((p: any) => p.type?.startsWith('data-'));
211+
expect(hasDataPart).toBe(false);
212+
}
213+
214+
// Text part should still be present
215+
if (typeof assistantMsg!.content !== 'string') {
216+
const hasTextPart = assistantMsg!.content.some((p: any) => p.type === 'text');
217+
expect(hasTextPart).toBe(true);
218+
}
219+
});
220+
221+
it('should filter out data-* parts from aiV5.llmPrompt() for Gemini compatibility', async () => {
222+
const list = new MessageList();
223+
224+
list.add({ role: 'user', content: 'Show me progress' }, 'input');
225+
226+
// Simulate assistant response with multiple data parts
227+
const assistantWithDataParts: MastraDBMessage = {
228+
id: 'msg-with-progress',
229+
role: 'assistant',
230+
createdAt: new Date(),
231+
content: {
232+
format: 2,
233+
parts: [
234+
{ type: 'text', text: 'Processing...' },
235+
{
236+
type: 'data-progress',
237+
data: { percent: 50, status: 'in-progress' },
238+
} as any,
239+
{
240+
type: 'data-file-reference',
241+
data: { fileId: 'file-123' },
242+
} as any,
243+
],
244+
content: 'Processing...',
245+
},
246+
};
247+
248+
list.add(assistantWithDataParts, 'response');
249+
list.add({ role: 'user', content: 'What is the status?' }, 'input');
250+
251+
const llmPrompt = await list.get.all.aiV5.llmPrompt();
252+
253+
// Find the assistant message in the prompt
254+
const assistantMsg = llmPrompt.find(m => m.role === 'assistant');
255+
expect(assistantMsg).toBeDefined();
256+
257+
// The assistant message content should NOT contain any data-* parts
258+
if (typeof assistantMsg!.content !== 'string') {
259+
const dataPartsCount = assistantMsg!.content.filter((p: any) => p.type?.startsWith('data-')).length;
260+
expect(dataPartsCount).toBe(0);
261+
}
262+
});
263+
264+
it('should preserve data-* parts in UI messages but filter from model messages', () => {
265+
const list = new MessageList();
266+
267+
list.add({ role: 'user', content: 'Test' }, 'input');
268+
269+
const assistantWithDataParts: MastraDBMessage = {
270+
id: 'msg-test',
271+
role: 'assistant',
272+
createdAt: new Date(),
273+
content: {
274+
format: 2,
275+
parts: [{ type: 'text', text: 'Response' }, { type: 'data-custom', data: { key: 'value' } } as any],
276+
content: 'Response',
277+
},
278+
};
279+
280+
list.add(assistantWithDataParts, 'response');
281+
282+
// UI messages should preserve data-* parts (for UI rendering)
283+
const uiMessages = list.get.all.aiV5.ui();
284+
const uiAssistant = uiMessages.find(m => m.role === 'assistant');
285+
expect(uiAssistant).toBeDefined();
286+
const hasDataPartInUI = uiAssistant!.parts.some((p: any) => p.type?.startsWith('data-'));
287+
expect(hasDataPartInUI).toBe(true);
288+
289+
// Model messages (for LLM) should NOT have data-* parts
290+
const modelMessages = list.get.all.aiV5.model();
291+
const modelAssistant = modelMessages.find(m => m.role === 'assistant');
292+
expect(modelAssistant).toBeDefined();
293+
294+
if (typeof modelAssistant!.content !== 'string') {
295+
const dataPartsInModel = modelAssistant!.content.filter((p: any) => p.type?.startsWith('data-'));
296+
// Data-* parts should be filtered out by AIV5.convertToModelMessages
297+
expect(dataPartsInModel.length).toBe(0);
298+
}
299+
});
300+
301+
it('should not remove messages that only have data-* parts (preserve empty text)', () => {
302+
const list = new MessageList();
303+
304+
list.add({ role: 'user', content: 'Generate data' }, 'input');
305+
306+
// Assistant responds with only custom data (no text)
307+
const assistantOnlyData: MastraDBMessage = {
308+
id: 'msg-only-data',
309+
role: 'assistant',
310+
createdAt: new Date(),
311+
content: {
312+
format: 2,
313+
parts: [{ type: 'data-result', data: { success: true } } as any],
314+
content: '',
315+
},
316+
};
317+
318+
list.add(assistantOnlyData, 'response');
319+
list.add({ role: 'user', content: 'Next question' }, 'input');
320+
321+
// The prompt should still be valid - either the message is removed or has empty text
322+
// What's important is that it doesn't crash Gemini with invalid parts
323+
const prompt = list.get.all.aiV5.prompt();
324+
325+
// Verify no data-* parts exist in any message
326+
for (const msg of prompt) {
327+
if (msg.role !== 'system' && typeof msg.content !== 'string') {
328+
const hasDataPart = msg.content.some((p: any) => p.type?.startsWith('data-'));
329+
expect(hasDataPart).toBe(false);
330+
}
331+
}
332+
});
333+
334+
it('AI SDK convertToModelMessages should filter out data-* parts', () => {
335+
// This test verifies the AI SDK behavior that we rely on
336+
const uiMessages = [
337+
{
338+
id: 'test-1',
339+
role: 'user' as const,
340+
parts: [{ type: 'text' as const, text: 'Hello' }],
341+
},
342+
{
343+
id: 'test-2',
344+
role: 'assistant' as const,
345+
parts: [
346+
{ type: 'text' as const, text: 'Here is your data:' },
347+
{ type: 'data-custom', data: { key: 'value' } } as any,
348+
],
349+
},
350+
];
351+
352+
const modelMessages = convertToModelMessages(uiMessages);
353+
354+
// Find the assistant message
355+
const assistantModel = modelMessages.find(m => m.role === 'assistant');
356+
expect(assistantModel).toBeDefined();
357+
358+
// Verify data-* parts are filtered out by the AI SDK
359+
if (typeof assistantModel!.content !== 'string') {
360+
const hasDataPart = assistantModel!.content.some((p: any) => p.type?.startsWith('data-'));
361+
expect(hasDataPart).toBe(false);
362+
}
363+
});
364+
365+
it('should not produce messages with empty content arrays - Issue #12363', () => {
366+
// Issue #12363: After using writer.custom() in a tool, subsequent messages fail with Gemini
367+
// Error: "Unable to submit request because it must include at least one parts field"
368+
//
369+
// Root cause: When a message contains ONLY data-* parts (custom parts from writer.custom()),
370+
// the AI SDK's convertToModelMessages creates a message with an empty content array.
371+
// Gemini rejects messages with empty content arrays.
372+
//
373+
// Fix: sanitizeV5UIMessages now filters out data-* parts before conversion,
374+
// and messages with only data-* parts are removed entirely.
375+
const list = new MessageList();
376+
377+
list.add({ role: 'user', content: 'Run the tool' }, 'input');
378+
379+
// Simulate assistant response with ONLY data-* parts (typical when writer.custom() is used without text)
380+
const assistantOnlyDataParts: MastraDBMessage = {
381+
id: 'assistant-with-only-data',
382+
role: 'assistant',
383+
createdAt: new Date(),
384+
content: {
385+
format: 2,
386+
parts: [
387+
{ type: 'data-progress', data: { percent: 50 } } as any,
388+
{ type: 'data-chart', data: { chartType: 'bar' } } as any,
389+
],
390+
content: '', // No text content
391+
},
392+
};
393+
394+
list.add(assistantOnlyDataParts, 'response');
395+
list.add({ role: 'user', content: 'Continue the conversation' }, 'input');
396+
397+
// Get the prompt that would be sent to Gemini
398+
const prompt = list.get.all.aiV5.prompt();
399+
400+
// CRITICAL: No messages should have empty content arrays
401+
// This would cause: "Unable to submit request because it must include at least one parts field"
402+
for (const msg of prompt) {
403+
if (typeof msg.content !== 'string') {
404+
expect(msg.content.length).toBeGreaterThan(0);
405+
}
406+
}
407+
408+
// The assistant message with only data-* parts should be removed entirely
409+
// (only user messages should remain)
410+
expect(prompt.filter(m => m.role === 'assistant').length).toBe(0);
411+
expect(prompt.filter(m => m.role === 'user').length).toBe(2);
412+
});
413+
414+
it('AI SDK convertToModelMessages produces empty content arrays for data-only messages (documents SDK behavior)', () => {
415+
// This test DOCUMENTS the AI SDK behavior that we work around.
416+
// The AI SDK's convertToModelMessages produces empty content arrays
417+
// for messages that have only data-* parts.
418+
// Our fix in sanitizeV5UIMessages filters these parts BEFORE calling convertToModelMessages.
419+
const uiMessages = [
420+
{
421+
id: 'test-1',
422+
role: 'user' as const,
423+
parts: [{ type: 'text' as const, text: 'Hello' }],
424+
},
425+
{
426+
id: 'test-2',
427+
role: 'assistant' as const,
428+
parts: [
429+
{ type: 'data-progress', data: { percent: 50 } } as any,
430+
{ type: 'data-result', data: { success: true } } as any,
431+
],
432+
},
433+
{
434+
id: 'test-3',
435+
role: 'user' as const,
436+
parts: [{ type: 'text' as const, text: 'Next message' }],
437+
},
438+
];
439+
440+
const modelMessages = convertToModelMessages(uiMessages);
441+
442+
// Find the assistant message (which had only data-* parts)
443+
const assistantMsg = modelMessages.find(m => m.role === 'assistant');
444+
expect(assistantMsg).toBeDefined();
445+
446+
// The AI SDK produces an empty content array
447+
// This is why we filter data-* parts in sanitizeV5UIMessages before conversion
448+
expect(typeof assistantMsg!.content).not.toBe('string');
449+
expect((assistantMsg!.content as any[]).length).toBe(0);
450+
});
451+
452+
it('should preserve data-* parts in DB/UI but filter from model/prompt messages', () => {
453+
const list = new MessageList();
454+
455+
// Add user message
456+
list.add({ role: 'user', content: 'Run the tool' }, 'input');
457+
458+
// Add assistant message with mixed parts (text + data-*)
459+
const mixedAssistant: MastraDBMessage = {
460+
id: 'mixed-msg',
461+
role: 'assistant',
462+
createdAt: new Date(),
463+
content: {
464+
format: 2,
465+
parts: [{ type: 'text', text: 'Processing...' }, { type: 'data-progress', data: { percent: 50 } } as any],
466+
content: 'Processing...',
467+
},
468+
};
469+
list.add(mixedAssistant, 'response');
470+
471+
// Add assistant message with ONLY data-* parts
472+
const dataOnlyAssistant: MastraDBMessage = {
473+
id: 'data-only-msg',
474+
role: 'assistant',
475+
createdAt: new Date(),
476+
content: {
477+
format: 2,
478+
parts: [{ type: 'data-chart', data: { type: 'bar' } } as any],
479+
content: '',
480+
},
481+
};
482+
list.add(dataOnlyAssistant, 'response');
483+
484+
// 1. DB storage should preserve ALL parts including data-*
485+
const dbMessages = list.get.all.db();
486+
const dbAssistant = dbMessages.find(m => m.id === 'mixed-msg');
487+
expect(dbAssistant?.content.parts?.map(p => p.type)).toContain('data-progress');
488+
489+
// 2. UI messages should preserve ALL parts (needed for frontend rendering)
490+
const uiMessages = list.get.all.aiV5.ui();
491+
const uiAssistant = uiMessages.find(m => m.id === 'mixed-msg');
492+
expect(uiAssistant?.parts.map(p => p.type)).toContain('data-progress');
493+
494+
// 3. Model messages should NOT have data-* parts
495+
const modelMessages = list.get.all.aiV5.model();
496+
for (const msg of modelMessages) {
497+
if (msg.role === 'assistant' && typeof msg.content !== 'string') {
498+
const hasDataPart = msg.content.some((p: any) => p.type?.startsWith('data-'));
499+
expect(hasDataPart).toBe(false);
500+
}
501+
}
502+
503+
// 4. Prompt messages should NOT have data-* parts or empty content arrays
504+
const promptMessages = list.get.all.aiV5.prompt();
505+
for (const msg of promptMessages) {
506+
if (typeof msg.content !== 'string') {
507+
// No data-* parts
508+
const hasDataPart = msg.content.some((p: any) => p.type?.startsWith('data-'));
509+
expect(hasDataPart).toBe(false);
510+
// No empty content arrays
511+
expect(msg.content.length).toBeGreaterThan(0);
512+
}
513+
}
514+
});
515+
});
516+
165517
describe('Agent Network scenarios', () => {
166518
it('should handle agent network memory pattern correctly', () => {
167519
const list = new MessageList();

0 commit comments

Comments
 (0)