Skip to content

Commit d9db7e1

Browse files
committed
test(ai): address message ID review feedback
1 parent adcbf5d commit d9db7e1

3 files changed

Lines changed: 162 additions & 4 deletions

File tree

packages/ai/tests/message-converters.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,18 @@ describe('Message Converters', () => {
403403
])
404404
})
405405

406+
it('should preserve the UI message id on an empty assistant fallback', () => {
407+
const uiMessage: UIMessage = {
408+
id: 'assistant-empty',
409+
role: 'assistant',
410+
parts: [],
411+
}
412+
413+
expect(uiMessageToModelMessages(uiMessage)).toEqual([
414+
{ id: uiMessage.id, role: 'assistant', content: null },
415+
])
416+
})
417+
406418
it('should preserve interleaving of text, tool calls, and tool results', () => {
407419
const uiMessage: UIMessage = {
408420
id: 'msg-1',

testing/e2e/src/routes/api.message-ids.ts

Lines changed: 118 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,110 @@
11
import { createFileRoute } from '@tanstack/react-router'
22
import { convertMessagesToModelMessages } from '@tanstack/ai'
3-
import type { ModelMessage, UIMessage } from '@tanstack/ai'
3+
import { z } from 'zod'
4+
import type { UIMessage } from '@tanstack/ai'
5+
6+
const sourceSchema = z.discriminatedUnion('type', [
7+
z.object({
8+
type: z.literal('data'),
9+
value: z.string(),
10+
mimeType: z.string(),
11+
}),
12+
z.object({
13+
type: z.literal('url'),
14+
value: z.string(),
15+
mimeType: z.string().optional(),
16+
}),
17+
])
18+
19+
const contentPartSchema = z.discriminatedUnion('type', [
20+
z.object({
21+
type: z.literal('text'),
22+
content: z.string(),
23+
metadata: z.unknown().optional(),
24+
}),
25+
...(['image', 'audio', 'video', 'document'] as const).map((type) =>
26+
z.object({
27+
type: z.literal(type),
28+
source: sourceSchema,
29+
metadata: z.unknown().optional(),
30+
}),
31+
),
32+
])
33+
34+
const messagePartSchema = z.discriminatedUnion('type', [
35+
...contentPartSchema.options,
36+
z.object({
37+
type: z.literal('tool-call'),
38+
id: z.string(),
39+
name: z.string(),
40+
arguments: z.string(),
41+
input: z.unknown().optional(),
42+
state: z.enum([
43+
'awaiting-input',
44+
'input-streaming',
45+
'input-complete',
46+
'approval-requested',
47+
'approval-responded',
48+
'complete',
49+
'error',
50+
]),
51+
approval: z
52+
.object({
53+
id: z.string(),
54+
needsApproval: z.boolean(),
55+
approved: z.boolean().optional(),
56+
})
57+
.optional(),
58+
output: z.unknown().optional(),
59+
metadata: z.unknown().optional(),
60+
}),
61+
z.object({
62+
type: z.literal('tool-result'),
63+
toolCallId: z.string(),
64+
content: z.union([z.string(), z.array(contentPartSchema)]),
65+
state: z.enum(['streaming', 'complete', 'error']),
66+
error: z.string().optional(),
67+
}),
68+
z.object({
69+
type: z.literal('thinking'),
70+
content: z.string(),
71+
stepId: z.string().optional(),
72+
signature: z.string().optional(),
73+
}),
74+
z.object({
75+
type: z.literal('structured-output'),
76+
status: z.enum(['streaming', 'complete', 'error']),
77+
partial: z.unknown().optional(),
78+
data: z.unknown().optional(),
79+
raw: z.string(),
80+
reasoning: z.string().optional(),
81+
errorMessage: z.string().optional(),
82+
}),
83+
z.object({
84+
type: z.literal('ui-resource'),
85+
resource: z.object({
86+
uri: z.string(),
87+
mimeType: z.string(),
88+
text: z.string().optional(),
89+
blob: z.string().optional(),
90+
}),
91+
serverId: z.string().optional(),
92+
toolCallId: z.string(),
93+
toolName: z.string(),
94+
meta: z.record(z.string(), z.unknown()).optional(),
95+
}),
96+
])
97+
98+
const requestBodySchema: z.ZodType<{ messages: Array<UIMessage> }> = z.object({
99+
messages: z.array(
100+
z.object({
101+
id: z.string(),
102+
role: z.enum(['system', 'user', 'assistant']),
103+
parts: z.array(messagePartSchema),
104+
createdAt: z.coerce.date().optional(),
105+
}),
106+
),
107+
})
4108

5109
/**
6110
* Provider-free harness for the UIMessage -> ModelMessage identity contract.
@@ -11,11 +115,21 @@ export const Route = createFileRoute('/api/message-ids')({
11115
server: {
12116
handlers: {
13117
POST: async ({ request }) => {
14-
const body = (await request.json()) as {
15-
messages: Array<UIMessage | ModelMessage>
118+
let body: unknown
119+
try {
120+
body = await request.json()
121+
} catch {
122+
return new Response('Invalid JSON request body', { status: 400 })
123+
}
124+
125+
const parsed = requestBodySchema.safeParse(body)
126+
if (!parsed.success) {
127+
return new Response('Invalid message data', { status: 400 })
16128
}
17129

18-
return Response.json(convertMessagesToModelMessages(body.messages))
130+
return Response.json(
131+
convertMessagesToModelMessages(parsed.data.messages),
132+
)
19133
},
20134
},
21135
},

testing/e2e/tests/chat.spec.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import {
77
} from './helpers'
88
import { providersFor } from './test-matrix'
99

10+
// The server conversion test in this spec does not call a provider HTTP
11+
// endpoint, so it intentionally does not configure aimock.
12+
1013
for (const provider of providersFor('chat')) {
1114
test.describe(`${provider} — chat`, () => {
1215
test('sends a message and receives a streaming response', async ({
@@ -114,6 +117,35 @@ test('preserves UI message IDs at the server conversion boundary', async ({
114117
])
115118
})
116119

120+
test('rejects malformed JSON at the server conversion boundary', async ({
121+
request,
122+
}) => {
123+
const response = await request.post('/api/message-ids', {
124+
data: '{',
125+
headers: { 'Content-Type': 'application/json' },
126+
})
127+
128+
expect(response.status()).toBe(400)
129+
})
130+
131+
test('rejects invalid message parts at the server conversion boundary', async ({
132+
request,
133+
}) => {
134+
const response = await request.post('/api/message-ids', {
135+
data: {
136+
messages: [
137+
{
138+
id: 'user-1',
139+
role: 'user',
140+
parts: [{ type: 'text', content: 42 }],
141+
},
142+
],
143+
},
144+
})
145+
146+
expect(response.status()).toBe(400)
147+
})
148+
117149
test.describe('openai chat persistence', () => {
118150
test('persists chat messages across browser reload with localStorage', async ({
119151
page,

0 commit comments

Comments
 (0)