Skip to content

Commit 2e51f3a

Browse files
committed
standardize chat on AI SDK contracts
1 parent 83ad4e0 commit 2e51f3a

29 files changed

Lines changed: 1007 additions & 386 deletions

app/api/chat/__tests__/route.test.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it, vi } from 'vitest'
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
22

33
// Mock all dependencies
44
vi.mock('next/cache', () => ({
@@ -87,6 +87,24 @@ function createRequest(body: any, headers?: Record<string, string>): Request {
8787
}
8888

8989
describe('POST /api/chat', () => {
90+
beforeEach(() => {
91+
vi.mocked(getCurrentUserId).mockReset()
92+
vi.mocked(getCurrentUserId).mockResolvedValue('user-123')
93+
94+
vi.mocked(isProviderEnabled).mockReset()
95+
vi.mocked(isProviderEnabled).mockReturnValue(true)
96+
97+
vi.mocked(createChatStreamResponse).mockReset()
98+
vi.mocked(createChatStreamResponse).mockResolvedValue(
99+
new Response('stream', { status: 200 })
100+
)
101+
102+
vi.mocked(createEphemeralChatStreamResponse).mockReset()
103+
vi.mocked(createEphemeralChatStreamResponse).mockResolvedValue(
104+
new Response('ephemeral-stream', { status: 200 })
105+
)
106+
})
107+
90108
it('returns 400 for unknown trigger', async () => {
91109
const req = createRequest({
92110
message: 'hi',
@@ -132,12 +150,17 @@ describe('POST /api/chat', () => {
132150
const res = await POST(req)
133151
expect(res.status).toBe(400)
134152
const json = await res.json()
135-
expect(json.message).toContain('message')
153+
expect(json.message).toContain('messages')
136154
})
137155

138156
it('returns 403 for requests from share pages', async () => {
139157
const req = createRequest(
140-
{ message: 'hi', chatId: 'c1', trigger: 'submit-message' },
158+
{
159+
message: 'hi',
160+
messages: [{ role: 'user', parts: [{ type: 'text', text: 'hi' }] }],
161+
chatId: 'c1',
162+
trigger: 'submit-message'
163+
},
141164
{ referer: 'http://localhost/share/abc123' }
142165
)
143166

@@ -153,6 +176,7 @@ describe('POST /api/chat', () => {
153176

154177
const req = createRequest({
155178
message: 'hi',
179+
messages: [{ role: 'user', parts: [{ type: 'text', text: 'hi' }] }],
156180
chatId: 'c1',
157181
trigger: 'submit-message'
158182
})
@@ -166,6 +190,7 @@ describe('POST /api/chat', () => {
166190

167191
const req = createRequest({
168192
message: 'hi',
193+
messages: [{ role: 'user', parts: [{ type: 'text', text: 'hi' }] }],
169194
chatId: 'c1',
170195
trigger: 'submit-message'
171196
})
@@ -177,6 +202,7 @@ describe('POST /api/chat', () => {
177202
it('calls createChatStreamResponse for authenticated users', async () => {
178203
const req = createRequest({
179204
message: { role: 'user', parts: [{ type: 'text', text: 'hello' }] },
205+
messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }],
180206
chatId: 'c1',
181207
trigger: 'submit-message',
182208
isNewChat: true

app/api/chat/route.ts

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ export async function POST(req: Request) {
4141
guestCanvasToken
4242
} = body
4343

44+
const normalizedMessages = Array.isArray(messages)
45+
? messages
46+
: typeof messages === 'undefined' && message?.parts
47+
? [message]
48+
: messages
49+
4450
perfLog(
4551
`API Route - Start: chatId=${chatId}, trigger=${trigger}, isNewChat=${isNewChat}`
4652
)
@@ -84,10 +90,13 @@ export async function POST(req: Request) {
8490
`[tool-result] Received continuation: chatId=${chatId}, toolCallId=${toolResult.toolCallId}`
8591
)
8692
} else if (validatedTrigger === 'submit-message') {
87-
if (!message) {
93+
if (
94+
!Array.isArray(normalizedMessages) ||
95+
normalizedMessages.length === 0
96+
) {
8897
return jsonError(
8998
'BAD_REQUEST',
90-
'message is required for submission',
99+
'messages are required for submission',
91100
400
92101
)
93102
}
@@ -171,9 +180,9 @@ export async function POST(req: Request) {
171180
// Validate guest messages shape at the system boundary
172181
if (isGuest) {
173182
if (
174-
!Array.isArray(messages) ||
175-
messages.length === 0 ||
176-
!messages.every(
183+
!Array.isArray(normalizedMessages) ||
184+
normalizedMessages.length === 0 ||
185+
!normalizedMessages.every(
177186
(m: any) =>
178187
(m.role === 'user' || m.role === 'assistant') &&
179188
Array.isArray(m.parts) &&
@@ -198,11 +207,9 @@ export async function POST(req: Request) {
198207
}
199208

200209
// Validate file parts for all users (guests and authenticated)
201-
const messagesToValidate = isGuest
202-
? messages
203-
: message?.parts
204-
? [message]
205-
: []
210+
const messagesToValidate = Array.isArray(normalizedMessages)
211+
? normalizedMessages
212+
: []
206213

207214
for (const msg of messagesToValidate) {
208215
if (!Array.isArray(msg.parts)) continue
@@ -218,7 +225,7 @@ export async function POST(req: Request) {
218225

219226
const response = isGuest
220227
? await createEphemeralChatStreamResponse({
221-
messages,
228+
messages: normalizedMessages ?? [],
222229
model: selectedModel,
223230
abortSignal,
224231
searchMode,
@@ -231,6 +238,9 @@ export async function POST(req: Request) {
231238
})
232239
: await createChatStreamResponse({
233240
message: validatedTrigger === 'tool-result' ? null : message,
241+
messages: Array.isArray(normalizedMessages)
242+
? normalizedMessages
243+
: undefined,
234244
model: selectedModel,
235245
chatId,
236246
userId: userId, // userId is guaranteed to be non-null after authentication check above

components/chat-request.test.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ describe('chat request helpers', () => {
2727
})
2828
})
2929

30-
it('builds a tool-result continuation request for guest users', () => {
30+
it('builds a submit-message continuation request with canonical messages', () => {
3131
const request = buildChatRequestBody({
3232
messages: [
3333
{
@@ -52,13 +52,12 @@ describe('chat request helpers', () => {
5252

5353
expect(request).toEqual({
5454
body: {
55-
trigger: 'tool-result',
55+
trigger: 'submit-message',
5656
chatId: 'chat-1',
57-
toolResult: {
58-
toolCallId: 'tool-1',
59-
output: { value: 'dark' }
60-
},
61-
messages: expect.any(Array)
57+
messageId: undefined,
58+
messages: expect.any(Array),
59+
message: expect.any(Object),
60+
isNewChat: false
6261
}
6362
})
6463
})

components/chat-request.ts

Lines changed: 2 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { CanvasArtifactStatusData, UIMessage } from '@/lib/types/ai'
2-
import { isInteractiveToolPart } from '@/lib/types/dynamic-tools'
32

43
/**
54
* Search messages for the most recent `data-canvasArtifactStatus` part
@@ -32,7 +31,7 @@ export function buildChatRequestBody({
3231
trigger,
3332
messageId,
3433
chatId,
35-
isGuest,
34+
isGuest: _isGuest,
3635
savedMessagesCount,
3736
guestCanvasToken
3837
}: {
@@ -50,43 +49,12 @@ export function buildChatRequestBody({
5049
? messages.find(message => message.id === messageId)
5150
: undefined
5251

53-
const isToolResultContinuation =
54-
trigger === 'submit-message' && lastMessage?.role === 'assistant'
55-
56-
if (isToolResultContinuation) {
57-
// Use findLast to get the most recently resolved interactive tool part.
58-
// When multiple displayOptionList calls are resolved sequentially,
59-
// the first one is already persisted — we need the latest one.
60-
const resolvedPart = lastMessage?.parts?.findLast(
61-
part =>
62-
isInteractiveToolPart(part) &&
63-
'state' in part &&
64-
part.state === 'output-available' &&
65-
'output' in part
66-
) as { toolCallId: string; output: unknown } | undefined
67-
68-
if (resolvedPart && resolvedPart.output !== undefined) {
69-
return {
70-
body: {
71-
trigger: 'tool-result' as const,
72-
chatId,
73-
toolResult: {
74-
toolCallId: resolvedPart.toolCallId,
75-
output: resolvedPart.output
76-
},
77-
...(isGuest ? { messages } : {}),
78-
...(guestCanvasToken ? { guestCanvasToken } : {})
79-
}
80-
}
81-
}
82-
}
83-
8452
return {
8553
body: {
8654
trigger,
8755
chatId,
8856
messageId,
89-
...(isGuest ? { messages } : {}),
57+
messages,
9058
...(guestCanvasToken ? { guestCanvasToken } : {}),
9159
message:
9260
trigger === 'regenerate-message' && messageToRegenerate?.role === 'user'

components/chat.test.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ function makeUseChatReturnValue(messages: UIMessage[] = []) {
9090
stop: vi.fn(),
9191
sendMessage: vi.fn(),
9292
regenerate: vi.fn(),
93-
addToolResult: vi.fn(),
93+
addToolOutput: vi.fn(),
9494
error: null
9595
}
9696
}
@@ -107,7 +107,8 @@ vi.mock('@ai-sdk/react', () => ({
107107
}))
108108

109109
vi.mock('ai', () => ({
110-
DefaultChatTransport: class DefaultChatTransport {}
110+
DefaultChatTransport: class DefaultChatTransport {},
111+
lastAssistantMessageIsCompleteWithToolCalls: vi.fn(() => false)
111112
}))
112113

113114
vi.mock('sonner', () => ({

components/chat.tsx

Lines changed: 7 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import {
1111
import { useRouter } from 'next/navigation'
1212

1313
import { useChat } from '@ai-sdk/react'
14-
import { DefaultChatTransport } from 'ai'
14+
import {
15+
DefaultChatTransport,
16+
lastAssistantMessageIsCompleteWithToolCalls
17+
} from 'ai'
1518
import { toast } from 'sonner'
1619

1720
import { generateId } from '@/lib/db/schema'
@@ -27,7 +30,6 @@ import type {
2730
} from '@/lib/types/ai'
2831
import {
2932
isDynamicToolPart,
30-
isInteractiveToolPart,
3133
isToolCallPart,
3234
isToolTypePart
3335
} from '@/lib/types/dynamic-tools'
@@ -115,7 +117,6 @@ export function Chat({
115117
// Clear other chat-related state that persists due to Next.js 16 component caching
116118
setInput('')
117119
setUploadedFiles([])
118-
autoSendFiredRef.current.clear()
119120
setErrorModal({
120121
open: false,
121122
type: 'general',
@@ -163,7 +164,6 @@ export function Chat({
163164
canvasRef.current.setGuestCanvasToken(token)
164165
}, [savedMessages])
165166

166-
const autoSendFiredRef = useRef<Set<string>>(new Set())
167167
const scrollContainerRef = useRef<HTMLDivElement>(null)
168168
const stopVoiceRef = useRef<(() => void) | null>(null)
169169
const lastVoiceErrorRef = useRef<string | null>(null)
@@ -195,7 +195,7 @@ export function Chat({
195195
stop,
196196
sendMessage,
197197
regenerate,
198-
addToolResult,
198+
addToolOutput,
199199
error
200200
} = useChat({
201201
id: chatId, // use the client-generated or provided chatId
@@ -326,36 +326,7 @@ export function Chat({
326326
toast.error(`Error in chat: ${errorMessage}`)
327327
}
328328
},
329-
sendAutomaticallyWhen: ({ messages: msgs }) => {
330-
const lastMsg = msgs[msgs.length - 1]
331-
if (!lastMsg || lastMsg.role !== 'assistant') return false
332-
const parts = lastMsg.parts
333-
if (!parts) return false
334-
// Check if any interactive tool parts are still pending (waiting for user input)
335-
const hasPendingTools = parts.some(
336-
p =>
337-
isInteractiveToolPart(p) &&
338-
'state' in p &&
339-
p.state === 'input-available' &&
340-
!('output' in p)
341-
)
342-
if (hasPendingTools) return false
343-
// Auto-continue when a displayOptionList has been resolved with a selection.
344-
// Use a ref to track which toolCallIds have already triggered auto-send
345-
// to prevent re-triggering on subsequent evaluations.
346-
// Find the first resolved part that hasn't already triggered auto-send
347-
// (not just the first resolved part — earlier ones may already be fired).
348-
const resolvedOptionPart = parts.find(
349-
(p: any) =>
350-
isInteractiveToolPart(p) &&
351-
'state' in p &&
352-
p.state === 'output-available' &&
353-
!autoSendFiredRef.current.has(p.toolCallId)
354-
) as { toolCallId: string } | undefined
355-
if (!resolvedOptionPart) return false
356-
autoSendFiredRef.current.add(resolvedOptionPart.toolCallId)
357-
return true
358-
},
329+
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
359330
experimental_throttle: 100,
360331
generateId
361332
})
@@ -378,7 +349,6 @@ export function Chat({
378349
setChatId(providedId)
379350
setInput('')
380351
setUploadedFiles([])
381-
autoSendFiredRef.current.clear()
382352
setErrorModal({
383353
open: false,
384354
type: 'general',
@@ -944,7 +914,7 @@ export function Chat({
944914
}
945915
}
946916

947-
addToolResult({ tool: toolName, toolCallId, output: result })
917+
addToolOutput({ tool: toolName, toolCallId, output: result })
948918
}}
949919
scrollContainerRef={scrollContainerRef}
950920
onUpdateMessage={handleUpdateAndReloadMessage}

0 commit comments

Comments
 (0)