Skip to content

Commit ac01b79

Browse files
lgrammeldavidroeca
andauthored
fix: UI message validation rejects persisted assistant responses with empty parts (#17201)
## Background Persisted chats could become unloadable when an errored assistant response was saved with an empty parts array. ## Summary Allowed assistant UI messages to have empty parts while retaining non-empty parts validation for user and system messages. ## Testing Added regression coverage for validateUIMessages and safeValidateUIMessages using chats ending with an empty assistant response. ## End-to-end Validation - `pnpm --filter ai build` followed by an inline `pnpm tsx` persisted-chat validation returned `{"success":true,"messageCount":2}`. ## Related Issues Fixes #11053 Co-authored-by: davidroeca <6155705+davidroeca@users.noreply.github.com>
1 parent 5be55f1 commit ac01b79

3 files changed

Lines changed: 87 additions & 9 deletions

File tree

‎.changeset/calm-chats-rest.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'ai': patch
3+
---
4+
5+
Allow validating assistant UI messages with empty parts so persisted errored responses remain loadable.

‎packages/ai/src/ui/validate-ui-messages.test.ts‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,36 @@ describe('validateUIMessages', () => {
7676
]]
7777
`);
7878
});
79+
80+
it('should validate chat ending with assistant message with empty parts array', async () => {
81+
const messages = await validateUIMessages({
82+
messages: [
83+
{
84+
id: '1',
85+
role: 'user',
86+
parts: [{ type: 'text', text: 'Hello' }],
87+
},
88+
{
89+
id: '2',
90+
role: 'assistant',
91+
parts: [],
92+
},
93+
],
94+
});
95+
96+
expect(messages).toEqual([
97+
{
98+
id: '1',
99+
role: 'user',
100+
parts: [{ type: 'text', text: 'Hello' }],
101+
},
102+
{
103+
id: '2',
104+
role: 'assistant',
105+
parts: [],
106+
},
107+
]);
108+
});
79109
});
80110

81111
describe('metadata', () => {
@@ -1708,6 +1738,37 @@ describe('safeValidateUIMessages', () => {
17081738
expect(result.error.message).toContain('Type validation failed');
17091739
});
17101740

1741+
it('should return success result for chat ending with assistant message with empty parts array', async () => {
1742+
const result = await safeValidateUIMessages({
1743+
messages: [
1744+
{
1745+
id: '1',
1746+
role: 'user',
1747+
parts: [{ type: 'text', text: 'Hello' }],
1748+
},
1749+
{
1750+
id: '2',
1751+
role: 'assistant',
1752+
parts: [],
1753+
},
1754+
],
1755+
});
1756+
1757+
expectToBe(result.success, true);
1758+
expect(result.data).toEqual([
1759+
{
1760+
id: '1',
1761+
role: 'user',
1762+
parts: [{ type: 'text', text: 'Hello' }],
1763+
},
1764+
{
1765+
id: '2',
1766+
role: 'assistant',
1767+
parts: [],
1768+
},
1769+
]);
1770+
});
1771+
17111772
it('should return failure result when metadata validation fails', async () => {
17121773
const result = await safeValidateUIMessages<UIMessage<{ foo: string }>>({
17131774
messages: [

‎packages/ai/src/ui/validate-ui-messages.ts‎

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,12 @@ const uiMessagesSchema = lazySchema(() =>
3030
zodSchema(
3131
z
3232
.array(
33-
z.object({
34-
id: z.string(),
35-
role: z.enum(['system', 'user', 'assistant']),
36-
metadata: z.unknown().optional(),
37-
parts: z
38-
.array(
33+
z
34+
.object({
35+
id: z.string(),
36+
role: z.enum(['system', 'user', 'assistant']),
37+
metadata: z.unknown().optional(),
38+
parts: z.array(
3939
z.union([
4040
z.object({
4141
type: z.literal('text'),
@@ -343,9 +343,21 @@ const uiMessagesSchema = lazySchema(() =>
343343
}),
344344
}),
345345
]),
346-
)
347-
.nonempty('Message must contain at least one part'),
348-
}),
346+
),
347+
})
348+
.superRefine((message, context) => {
349+
if (message.role !== 'assistant' && message.parts.length === 0) {
350+
context.addIssue({
351+
origin: 'array',
352+
code: 'too_small',
353+
minimum: 1,
354+
inclusive: true,
355+
input: message.parts,
356+
path: ['parts'],
357+
message: 'Message must contain at least one part',
358+
});
359+
}
360+
}),
349361
)
350362
.nonempty('Messages array must not be empty'),
351363
),

0 commit comments

Comments
 (0)