@@ -7,19 +7,22 @@ import {
7
7
import { cleanup , findByText , render , screen } from '@solidjs/testing-library' ;
8
8
import '@testing-library/jest-dom' ;
9
9
import userEvent from '@testing-library/user-event' ;
10
- import { For } from 'solid-js' ;
10
+ import { For , createSignal } from 'solid-js' ;
11
11
import { useChat } from './use-chat' ;
12
+ import { formatStreamPart } from '@ai-sdk/ui-utils' ;
12
13
13
14
describe ( 'stream data stream' , ( ) => {
14
15
const TestComponent = ( ) => {
15
- const { messages, append, error, data, isLoading } = useChat ( ) ;
16
+ const [ id , setId ] = createSignal ( 'first-id' ) ;
17
+ const { messages, append, error, data, isLoading } = useChat ( ( ) => ( {
18
+ id : id ( ) ,
19
+ } ) ) ;
16
20
17
21
return (
18
22
< div >
19
23
< div data-testid = "loading" > { isLoading ( ) . toString ( ) } </ div >
20
24
< div data-testid = "error" > { error ( ) ?. toString ( ) } </ div >
21
25
< div data-testid = "data" > { JSON . stringify ( data ( ) ) } </ div >
22
-
23
26
< For each = { messages ( ) } >
24
27
{ ( m , idx ) => (
25
28
< div data-testid = { `message-${ idx ( ) } ` } >
@@ -28,13 +31,18 @@ describe('stream data stream', () => {
28
31
</ div >
29
32
) }
30
33
</ For >
31
-
32
34
< button
33
- data-testid = "button "
35
+ data-testid = "do-append "
34
36
onClick = { ( ) => {
35
37
append ( { role : 'user' , content : 'hi' } ) ;
36
38
} }
37
39
/>
40
+ < button
41
+ data-testid = "do-change-id"
42
+ onClick = { ( ) => {
43
+ setId ( 'second-id' ) ;
44
+ } }
45
+ />
38
46
</ div >
39
47
) ;
40
48
} ;
@@ -48,13 +56,13 @@ describe('stream data stream', () => {
48
56
cleanup ( ) ;
49
57
} ) ;
50
58
51
- it ( 'should return messages ' , async ( ) => {
59
+ it ( 'should show streamed response ' , async ( ) => {
52
60
mockFetchDataStream ( {
53
61
url : 'https://example.com/api/chat' ,
54
62
chunks : [ '0:"Hello"\n' , '0:","\n' , '0:" world"\n' , '0:"."\n' ] ,
55
63
} ) ;
56
64
57
- await userEvent . click ( screen . getByTestId ( 'button ' ) ) ;
65
+ await userEvent . click ( screen . getByTestId ( 'do-append ' ) ) ;
58
66
59
67
await screen . findByTestId ( 'message-0' ) ;
60
68
expect ( screen . getByTestId ( 'message-0' ) ) . toHaveTextContent ( 'User: hi' ) ;
@@ -65,13 +73,13 @@ describe('stream data stream', () => {
65
73
) ;
66
74
} ) ;
67
75
68
- it ( 'should return messages and data' , async ( ) => {
76
+ it ( 'should show streamed response with data' , async ( ) => {
69
77
mockFetchDataStream ( {
70
78
url : 'https://example.com/api/chat' ,
71
79
chunks : [ '2:[{"t1":"v1"}]\n' , '0:"Hello"\n' ] ,
72
80
} ) ;
73
81
74
- await userEvent . click ( screen . getByTestId ( 'button ' ) ) ;
82
+ await userEvent . click ( screen . getByTestId ( 'do-append ' ) ) ;
75
83
76
84
await screen . findByTestId ( 'data' ) ;
77
85
expect ( screen . getByTestId ( 'data' ) ) . toHaveTextContent ( '[{"t1":"v1"}]' ) ;
@@ -80,10 +88,10 @@ describe('stream data stream', () => {
80
88
expect ( screen . getByTestId ( 'message-1' ) ) . toHaveTextContent ( 'AI: Hello' ) ;
81
89
} ) ;
82
90
83
- it ( 'should return error' , async ( ) => {
91
+ it ( 'should show error response ' , async ( ) => {
84
92
mockFetchError ( { statusCode : 404 , errorMessage : 'Not found' } ) ;
85
93
86
- await userEvent . click ( screen . getByTestId ( 'button ' ) ) ;
94
+ await userEvent . click ( screen . getByTestId ( 'do-append ' ) ) ;
87
95
88
96
await screen . findByTestId ( 'error' ) ;
89
97
expect ( screen . getByTestId ( 'error' ) ) . toHaveTextContent ( 'Error: Not found' ) ;
@@ -105,7 +113,7 @@ describe('stream data stream', () => {
105
113
} ) ( ) ,
106
114
} ) ;
107
115
108
- await userEvent . click ( screen . getByTestId ( 'button ' ) ) ;
116
+ await userEvent . click ( screen . getByTestId ( 'do-append ' ) ) ;
109
117
110
118
await screen . findByTestId ( 'loading' ) ;
111
119
expect ( screen . getByTestId ( 'loading' ) ) . toHaveTextContent ( 'true' ) ;
@@ -119,19 +127,39 @@ describe('stream data stream', () => {
119
127
it ( 'should reset loading state on error' , async ( ) => {
120
128
mockFetchError ( { statusCode : 404 , errorMessage : 'Not found' } ) ;
121
129
122
- await userEvent . click ( screen . getByTestId ( 'button ' ) ) ;
130
+ await userEvent . click ( screen . getByTestId ( 'do-append ' ) ) ;
123
131
124
132
await screen . findByTestId ( 'loading' ) ;
125
133
expect ( screen . getByTestId ( 'loading' ) ) . toHaveTextContent ( 'false' ) ;
126
134
} ) ;
127
135
} ) ;
136
+
137
+ describe ( 'id' , ( ) => {
138
+ it ( 'should clear out messages when the id changes' , async ( ) => {
139
+ mockFetchDataStream ( {
140
+ url : 'https://example.com/api/chat' ,
141
+ chunks : [ '0:"Hello"\n' , '0:","\n' , '0:" world"\n' , '0:"."\n' ] ,
142
+ } ) ;
143
+
144
+ await userEvent . click ( screen . getByTestId ( 'do-append' ) ) ;
145
+
146
+ await screen . findByTestId ( 'message-1' ) ;
147
+ expect ( screen . getByTestId ( 'message-1' ) ) . toHaveTextContent (
148
+ 'AI: Hello, world.' ,
149
+ ) ;
150
+
151
+ await userEvent . click ( screen . getByTestId ( 'do-change-id' ) ) ;
152
+
153
+ expect ( screen . queryByTestId ( 'message-0' ) ) . not . toBeInTheDocument ( ) ;
154
+ } ) ;
155
+ } ) ;
128
156
} ) ;
129
157
130
158
describe ( 'text stream' , ( ) => {
131
159
const TestComponent = ( ) => {
132
- const { messages, append } = useChat ( {
160
+ const { messages, append } = useChat ( ( ) => ( {
133
161
streamMode : 'text' ,
134
- } ) ;
162
+ } ) ) ;
135
163
136
164
return (
137
165
< div >
@@ -182,3 +210,212 @@ describe('text stream', () => {
182
210
) ;
183
211
} ) ;
184
212
} ) ;
213
+
214
+ describe ( 'onToolCall' , ( ) => {
215
+ const TestComponent = ( ) => {
216
+ const { messages, append } = useChat ( ( ) => ( {
217
+ async onToolCall ( { toolCall } ) {
218
+ return `test-tool-response: ${ toolCall . toolName } ${
219
+ toolCall . toolCallId
220
+ } ${ JSON . stringify ( toolCall . args ) } `;
221
+ } ,
222
+ } ) ) ;
223
+
224
+ return (
225
+ < div >
226
+ < For each = { messages ( ) } >
227
+ { ( m , idx ) => (
228
+ < div data-testid = { `message-${ idx ( ) } ` } >
229
+ < For each = { m . toolInvocations ?? [ ] } >
230
+ { ( toolInvocation , toolIdx ) =>
231
+ 'result' in toolInvocation ? (
232
+ < div data-testid = { `tool-invocation-${ toolIdx ( ) } ` } >
233
+ { toolInvocation . result }
234
+ </ div >
235
+ ) : null
236
+ }
237
+ </ For >
238
+ </ div >
239
+ ) }
240
+ </ For >
241
+
242
+ < button
243
+ data-testid = "do-append"
244
+ onClick = { ( ) => {
245
+ append ( { role : 'user' , content : 'hi' } ) ;
246
+ } }
247
+ />
248
+ </ div >
249
+ ) ;
250
+ } ;
251
+
252
+ beforeEach ( ( ) => {
253
+ render ( ( ) => < TestComponent /> ) ;
254
+ } ) ;
255
+
256
+ afterEach ( ( ) => {
257
+ vi . restoreAllMocks ( ) ;
258
+ cleanup ( ) ;
259
+ } ) ;
260
+
261
+ it ( "should invoke onToolCall when a tool call is received from the server's response" , async ( ) => {
262
+ mockFetchDataStream ( {
263
+ url : 'https://example.com/api/chat' ,
264
+ chunks : [
265
+ formatStreamPart ( 'tool_call' , {
266
+ toolCallId : 'tool-call-0' ,
267
+ toolName : 'test-tool' ,
268
+ args : { testArg : 'test-value' } ,
269
+ } ) ,
270
+ ] ,
271
+ } ) ;
272
+
273
+ await userEvent . click ( screen . getByTestId ( 'do-append' ) ) ;
274
+
275
+ await screen . findByTestId ( 'message-1' ) ;
276
+ expect ( screen . getByTestId ( 'message-1' ) ) . toHaveTextContent (
277
+ 'test-tool-response: test-tool tool-call-0 {"testArg":"test-value"}' ,
278
+ ) ;
279
+ } ) ;
280
+ } ) ;
281
+
282
+ describe ( 'maxToolRoundtrips' , ( ) => {
283
+ describe ( 'single automatic tool roundtrip' , ( ) => {
284
+ const TestComponent = ( ) => {
285
+ const { messages, append } = useChat ( ( ) => ( {
286
+ async onToolCall ( { toolCall } ) {
287
+ mockFetchDataStream ( {
288
+ url : 'https://example.com/api/chat' ,
289
+ chunks : [ formatStreamPart ( 'text' , 'final result' ) ] ,
290
+ } ) ;
291
+
292
+ return `test-tool-response: ${ toolCall . toolName } ${
293
+ toolCall . toolCallId
294
+ } ${ JSON . stringify ( toolCall . args ) } `;
295
+ } ,
296
+ maxToolRoundtrips : 5 ,
297
+ } ) ) ;
298
+
299
+ return (
300
+ < div >
301
+ < For each = { messages ( ) } >
302
+ { ( m , idx ) => (
303
+ < div data-testid = { `message-${ idx ( ) } ` } > { m . content } </ div >
304
+ ) }
305
+ </ For >
306
+
307
+ < button
308
+ data-testid = "do-append"
309
+ onClick = { ( ) => {
310
+ append ( { role : 'user' , content : 'hi' } ) ;
311
+ } }
312
+ />
313
+ </ div >
314
+ ) ;
315
+ } ;
316
+
317
+ beforeEach ( ( ) => {
318
+ render ( ( ) => < TestComponent /> ) ;
319
+ } ) ;
320
+
321
+ afterEach ( ( ) => {
322
+ vi . restoreAllMocks ( ) ;
323
+ cleanup ( ) ;
324
+ } ) ;
325
+
326
+ it ( 'should automatically call api when tool call gets executed via onToolCall' , async ( ) => {
327
+ mockFetchDataStream ( {
328
+ url : 'https://example.com/api/chat' ,
329
+ chunks : [
330
+ formatStreamPart ( 'tool_call' , {
331
+ toolCallId : 'tool-call-0' ,
332
+ toolName : 'test-tool' ,
333
+ args : { testArg : 'test-value' } ,
334
+ } ) ,
335
+ ] ,
336
+ } ) ;
337
+
338
+ await userEvent . click ( screen . getByTestId ( 'do-append' ) ) ;
339
+
340
+ await screen . findByTestId ( 'message-2' ) ;
341
+ expect ( screen . getByTestId ( 'message-2' ) ) . toHaveTextContent ( 'final result' ) ;
342
+ } ) ;
343
+ } ) ;
344
+
345
+ describe ( 'single roundtrip with error response' , ( ) => {
346
+ const TestComponent = ( ) => {
347
+ const { messages, append, error } = useChat ( ( ) => ( {
348
+ async onToolCall ( { toolCall } ) {
349
+ mockFetchDataStream ( {
350
+ url : 'https://example.com/api/chat' ,
351
+ chunks : [ formatStreamPart ( 'error' , 'some failure' ) ] ,
352
+ maxCalls : 1 ,
353
+ } ) ;
354
+
355
+ return `test-tool-response: ${ toolCall . toolName } ${
356
+ toolCall . toolCallId
357
+ } ${ JSON . stringify ( toolCall . args ) } `;
358
+ } ,
359
+ maxToolRoundtrips : 5 ,
360
+ } ) ) ;
361
+
362
+ return (
363
+ < div >
364
+ < div data-testid = "error" > { error ( ) ?. toString ( ) } </ div >
365
+
366
+ < For each = { messages ( ) } >
367
+ { ( m , idx ) => (
368
+ < div data-testid = { `message-${ idx ( ) } ` } >
369
+ < For each = { m . toolInvocations ?? [ ] } >
370
+ { ( toolInvocation , toolIdx ) =>
371
+ 'result' in toolInvocation ? (
372
+ < div data-testid = { `tool-invocation-${ toolIdx ( ) } ` } >
373
+ { toolInvocation . result }
374
+ </ div >
375
+ ) : null
376
+ }
377
+ </ For >
378
+ </ div >
379
+ ) }
380
+ </ For >
381
+
382
+ < button
383
+ data-testid = "do-append"
384
+ onClick = { ( ) => {
385
+ append ( { role : 'user' , content : 'hi' } ) ;
386
+ } }
387
+ />
388
+ </ div >
389
+ ) ;
390
+ } ;
391
+
392
+ beforeEach ( ( ) => {
393
+ render ( ( ) => < TestComponent /> ) ;
394
+ } ) ;
395
+
396
+ afterEach ( ( ) => {
397
+ vi . restoreAllMocks ( ) ;
398
+ cleanup ( ) ;
399
+ } ) ;
400
+
401
+ it ( 'should automatically call api when tool call gets executed via onToolCall' , async ( ) => {
402
+ mockFetchDataStream ( {
403
+ url : 'https://example.com/api/chat' ,
404
+ chunks : [
405
+ formatStreamPart ( 'tool_call' , {
406
+ toolCallId : 'tool-call-0' ,
407
+ toolName : 'test-tool' ,
408
+ args : { testArg : 'test-value' } ,
409
+ } ) ,
410
+ ] ,
411
+ } ) ;
412
+
413
+ await userEvent . click ( screen . getByTestId ( 'do-append' ) ) ;
414
+
415
+ await screen . findByTestId ( 'error' ) ;
416
+ expect ( screen . getByTestId ( 'error' ) ) . toHaveTextContent (
417
+ 'Error: Too many calls' ,
418
+ ) ;
419
+ } ) ;
420
+ } ) ;
421
+ } ) ;
0 commit comments