@ai-sdk/react 0.0.51 → 0.0.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1542 +0,0 @@
1
- /* eslint-disable @next/next/no-img-element */
2
- import { withTestServer } from '@ai-sdk/provider-utils/test';
3
- import {
4
- formatStreamPart,
5
- getTextFromDataUrl,
6
- Message,
7
- } from '@ai-sdk/ui-utils';
8
- import '@testing-library/jest-dom/vitest';
9
- import {
10
- cleanup,
11
- findByText,
12
- render,
13
- screen,
14
- waitFor,
15
- } from '@testing-library/react';
16
- import userEvent from '@testing-library/user-event';
17
- import React, { useRef, useState } from 'react';
18
- import { useChat } from './use-chat';
19
-
20
- describe('stream data stream', () => {
21
- let onFinishCalls: Array<{
22
- message: Message;
23
- options: {
24
- finishReason: string;
25
- usage: {
26
- completionTokens: number;
27
- promptTokens: number;
28
- totalTokens: number;
29
- };
30
- };
31
- }> = [];
32
-
33
- const TestComponent = () => {
34
- const [id, setId] = React.useState<string>('first-id');
35
- const { messages, append, error, data, isLoading } = useChat({
36
- id,
37
- onFinish: (message, options) => {
38
- onFinishCalls.push({ message, options });
39
- },
40
- });
41
-
42
- return (
43
- <div>
44
- <div data-testid="loading">{isLoading.toString()}</div>
45
- {error && <div data-testid="error">{error.toString()}</div>}
46
- {data && <div data-testid="data">{JSON.stringify(data)}</div>}
47
- {messages.map((m, idx) => (
48
- <div data-testid={`message-${idx}`} key={m.id}>
49
- {m.role === 'user' ? 'User: ' : 'AI: '}
50
- {m.content}
51
- </div>
52
- ))}
53
-
54
- <button
55
- data-testid="do-append"
56
- onClick={() => {
57
- append({ role: 'user', content: 'hi' });
58
- }}
59
- />
60
- <button
61
- data-testid="do-change-id"
62
- onClick={() => {
63
- setId('second-id');
64
- }}
65
- />
66
- </div>
67
- );
68
- };
69
-
70
- beforeEach(() => {
71
- render(<TestComponent />);
72
- onFinishCalls = [];
73
- });
74
-
75
- afterEach(() => {
76
- vi.restoreAllMocks();
77
- cleanup();
78
- onFinishCalls = [];
79
- });
80
-
81
- it(
82
- 'should show streamed response',
83
- withTestServer(
84
- {
85
- type: 'stream-values',
86
- url: '/api/chat',
87
- content: ['0:"Hello"\n', '0:","\n', '0:" world"\n', '0:"."\n'],
88
- },
89
- async () => {
90
- await userEvent.click(screen.getByTestId('do-append'));
91
-
92
- await screen.findByTestId('message-0');
93
- expect(screen.getByTestId('message-0')).toHaveTextContent('User: hi');
94
-
95
- await screen.findByTestId('message-1');
96
- expect(screen.getByTestId('message-1')).toHaveTextContent(
97
- 'AI: Hello, world.',
98
- );
99
- },
100
- ),
101
- );
102
-
103
- it(
104
- 'should show streamed response with data',
105
- withTestServer(
106
- {
107
- type: 'stream-values',
108
- url: '/api/chat',
109
- content: ['2:[{"t1":"v1"}]\n', '0:"Hello"\n'],
110
- },
111
- async () => {
112
- await userEvent.click(screen.getByTestId('do-append'));
113
-
114
- await screen.findByTestId('data');
115
- expect(screen.getByTestId('data')).toHaveTextContent('[{"t1":"v1"}]');
116
-
117
- await screen.findByTestId('message-1');
118
- expect(screen.getByTestId('message-1')).toHaveTextContent('AI: Hello');
119
- },
120
- ),
121
- );
122
-
123
- it(
124
- 'should show error response when there is a server error',
125
- withTestServer(
126
- { type: 'error', url: '/api/chat', status: 404, content: 'Not found' },
127
- async () => {
128
- await userEvent.click(screen.getByTestId('do-append'));
129
-
130
- await screen.findByTestId('error');
131
- expect(screen.getByTestId('error')).toHaveTextContent(
132
- 'Error: Not found',
133
- );
134
- },
135
- ),
136
- );
137
-
138
- it(
139
- 'should show error response when there is a streaming error',
140
- withTestServer(
141
- {
142
- type: 'stream-values',
143
- url: '/api/chat',
144
- content: ['3:"custom error message"\n'],
145
- },
146
- async () => {
147
- await userEvent.click(screen.getByTestId('do-append'));
148
-
149
- await screen.findByTestId('error');
150
- expect(screen.getByTestId('error')).toHaveTextContent(
151
- 'Error: custom error message',
152
- );
153
- },
154
- ),
155
- );
156
-
157
- describe('loading state', () => {
158
- it(
159
- 'should show loading state',
160
- withTestServer(
161
- { url: '/api/chat', type: 'controlled-stream' },
162
- async ({ streamController }) => {
163
- streamController.enqueue('0:"Hello"\n');
164
-
165
- await userEvent.click(screen.getByTestId('do-append'));
166
-
167
- await screen.findByTestId('loading');
168
- expect(screen.getByTestId('loading')).toHaveTextContent('true');
169
-
170
- streamController.close();
171
-
172
- await findByText(await screen.findByTestId('loading'), 'false');
173
- expect(screen.getByTestId('loading')).toHaveTextContent('false');
174
- },
175
- ),
176
- );
177
-
178
- it(
179
- 'should reset loading state on error',
180
- withTestServer(
181
- { type: 'error', url: '/api/chat', status: 404, content: 'Not found' },
182
- async () => {
183
- await userEvent.click(screen.getByTestId('do-append'));
184
-
185
- await screen.findByTestId('loading');
186
- expect(screen.getByTestId('loading')).toHaveTextContent('false');
187
- },
188
- ),
189
- );
190
- });
191
-
192
- it(
193
- 'should invoke onFinish when the stream finishes',
194
- withTestServer(
195
- {
196
- url: '/api/chat',
197
- type: 'stream-values',
198
- content: [
199
- formatStreamPart('text', 'Hello'),
200
- formatStreamPart('text', ','),
201
- formatStreamPart('text', ' world'),
202
- formatStreamPart('text', '.'),
203
- formatStreamPart('finish_message', {
204
- finishReason: 'stop',
205
- usage: { completionTokens: 1, promptTokens: 3 },
206
- }),
207
- ],
208
- },
209
- async () => {
210
- await userEvent.click(screen.getByTestId('do-append'));
211
-
212
- await screen.findByTestId('message-1');
213
-
214
- expect(onFinishCalls).toStrictEqual([
215
- {
216
- message: {
217
- id: expect.any(String),
218
- createdAt: expect.any(Date),
219
- role: 'assistant',
220
- content: 'Hello, world.',
221
- },
222
- options: {
223
- finishReason: 'stop',
224
- usage: {
225
- completionTokens: 1,
226
- promptTokens: 3,
227
- totalTokens: 4,
228
- },
229
- },
230
- },
231
- ]);
232
- },
233
- ),
234
- );
235
-
236
- describe('id', () => {
237
- it(
238
- 'should clear out messages when the id changes',
239
- withTestServer(
240
- {
241
- url: '/api/chat',
242
- type: 'stream-values',
243
- content: ['0:"Hello"\n', '0:","\n', '0:" world"\n', '0:"."\n'],
244
- },
245
- async () => {
246
- await userEvent.click(screen.getByTestId('do-append'));
247
-
248
- await screen.findByTestId('message-1');
249
- expect(screen.getByTestId('message-1')).toHaveTextContent(
250
- 'AI: Hello, world.',
251
- );
252
-
253
- await userEvent.click(screen.getByTestId('do-change-id'));
254
-
255
- expect(screen.queryByTestId('message-0')).not.toBeInTheDocument();
256
- },
257
- ),
258
- );
259
- });
260
- });
261
-
262
- describe('text stream', () => {
263
- let onFinishCalls: Array<{
264
- message: Message;
265
- options: {
266
- finishReason: string;
267
- usage: {
268
- completionTokens: number;
269
- promptTokens: number;
270
- totalTokens: number;
271
- };
272
- };
273
- }> = [];
274
-
275
- const TestComponent = () => {
276
- const { messages, append } = useChat({
277
- streamProtocol: 'text',
278
- onFinish: (message, options) => {
279
- onFinishCalls.push({ message, options });
280
- },
281
- });
282
-
283
- return (
284
- <div>
285
- {messages.map((m, idx) => (
286
- <div data-testid={`message-${idx}-text-stream`} key={m.id}>
287
- <div data-testid={`message-${idx}-id`}>{m.id}</div>
288
- <div data-testid={`message-${idx}-role`}>
289
- {m.role === 'user' ? 'User: ' : 'AI: '}
290
- </div>
291
- <div data-testid={`message-${idx}-content`}>{m.content}</div>
292
- </div>
293
- ))}
294
-
295
- <button
296
- data-testid="do-append-text-stream"
297
- onClick={() => {
298
- append({ role: 'user', content: 'hi' });
299
- }}
300
- />
301
- </div>
302
- );
303
- };
304
-
305
- beforeEach(() => {
306
- render(<TestComponent />);
307
- onFinishCalls = [];
308
- });
309
-
310
- afterEach(() => {
311
- vi.restoreAllMocks();
312
- cleanup();
313
- onFinishCalls = [];
314
- });
315
-
316
- it(
317
- 'should show streamed response',
318
- withTestServer(
319
- {
320
- url: '/api/chat',
321
- type: 'stream-values',
322
- content: ['Hello', ',', ' world', '.'],
323
- },
324
- async () => {
325
- await userEvent.click(screen.getByTestId('do-append-text-stream'));
326
-
327
- await screen.findByTestId('message-0-content');
328
- expect(screen.getByTestId('message-0-content')).toHaveTextContent('hi');
329
-
330
- await screen.findByTestId('message-1-content');
331
- expect(screen.getByTestId('message-1-content')).toHaveTextContent(
332
- 'Hello, world.',
333
- );
334
- },
335
- ),
336
- );
337
-
338
- it(
339
- 'should have stable message ids',
340
- withTestServer(
341
- { url: '/api/chat', type: 'controlled-stream' },
342
- async ({ streamController }) => {
343
- streamController.enqueue('He');
344
-
345
- await userEvent.click(screen.getByTestId('do-append-text-stream'));
346
-
347
- await screen.findByTestId('message-1-content');
348
- expect(screen.getByTestId('message-1-content')).toHaveTextContent('He');
349
-
350
- const id = screen.getByTestId('message-1-id').textContent;
351
-
352
- streamController.enqueue('llo');
353
- streamController.close();
354
-
355
- await screen.findByTestId('message-1-content');
356
- expect(screen.getByTestId('message-1-content')).toHaveTextContent(
357
- 'Hello',
358
- );
359
- expect(screen.getByTestId('message-1-id').textContent).toBe(id);
360
- },
361
- ),
362
- );
363
-
364
- it(
365
- 'should invoke onFinish when the stream finishes',
366
- withTestServer(
367
- {
368
- url: '/api/chat',
369
- type: 'stream-values',
370
- content: ['Hello', ',', ' world', '.'],
371
- },
372
- async () => {
373
- await userEvent.click(screen.getByTestId('do-append-text-stream'));
374
-
375
- await screen.findByTestId('message-1-text-stream');
376
-
377
- expect(onFinishCalls).toStrictEqual([
378
- {
379
- message: {
380
- id: expect.any(String),
381
- createdAt: expect.any(Date),
382
- role: 'assistant',
383
- content: 'Hello, world.',
384
- },
385
- options: {
386
- finishReason: 'unknown',
387
- usage: {
388
- completionTokens: NaN,
389
- promptTokens: NaN,
390
- totalTokens: NaN,
391
- },
392
- },
393
- },
394
- ]);
395
- },
396
- ),
397
- );
398
- });
399
-
400
- describe('form actions', () => {
401
- const TestComponent = () => {
402
- const { messages, handleSubmit, handleInputChange, isLoading, input } =
403
- useChat({ streamProtocol: 'text' });
404
-
405
- return (
406
- <div>
407
- {messages.map((m, idx) => (
408
- <div data-testid={`message-${idx}`} key={m.id}>
409
- {m.role === 'user' ? 'User: ' : 'AI: '}
410
- {m.content}
411
- </div>
412
- ))}
413
-
414
- <form onSubmit={handleSubmit}>
415
- <input
416
- value={input}
417
- placeholder="Send message..."
418
- onChange={handleInputChange}
419
- disabled={isLoading}
420
- data-testid="do-input"
421
- />
422
- </form>
423
- </div>
424
- );
425
- };
426
-
427
- beforeEach(() => {
428
- render(<TestComponent />);
429
- });
430
-
431
- afterEach(() => {
432
- vi.restoreAllMocks();
433
- cleanup();
434
- });
435
-
436
- it(
437
- 'should show streamed response using handleSubmit',
438
- withTestServer(
439
- [
440
- {
441
- url: '/api/chat',
442
- type: 'stream-values',
443
- content: ['Hello', ',', ' world', '.'],
444
- },
445
- {
446
- url: '/api/chat',
447
- type: 'stream-values',
448
- content: ['How', ' can', ' I', ' help', ' you', '?'],
449
- },
450
- ],
451
- async () => {
452
- const firstInput = screen.getByTestId('do-input');
453
- await userEvent.type(firstInput, 'hi');
454
- await userEvent.keyboard('{Enter}');
455
-
456
- await screen.findByTestId('message-0');
457
- expect(screen.getByTestId('message-0')).toHaveTextContent('User: hi');
458
-
459
- await screen.findByTestId('message-1');
460
- expect(screen.getByTestId('message-1')).toHaveTextContent(
461
- 'AI: Hello, world.',
462
- );
463
-
464
- const secondInput = screen.getByTestId('do-input');
465
- await userEvent.type(secondInput, '{Enter}');
466
-
467
- expect(screen.queryByTestId('message-2')).not.toBeInTheDocument();
468
- },
469
- ),
470
- );
471
- });
472
-
473
- describe('form actions (with options)', () => {
474
- const TestComponent = () => {
475
- const { messages, handleSubmit, handleInputChange, isLoading, input } =
476
- useChat({ streamProtocol: 'text' });
477
-
478
- return (
479
- <div>
480
- {messages.map((m, idx) => (
481
- <div data-testid={`message-${idx}`} key={m.id}>
482
- {m.role === 'user' ? 'User: ' : 'AI: '}
483
- {m.content}
484
- </div>
485
- ))}
486
-
487
- <form
488
- onSubmit={event => {
489
- handleSubmit(event, {
490
- allowEmptySubmit: true,
491
- });
492
- }}
493
- >
494
- <input
495
- value={input}
496
- placeholder="Send message..."
497
- onChange={handleInputChange}
498
- disabled={isLoading}
499
- data-testid="do-input"
500
- />
501
- </form>
502
- </div>
503
- );
504
- };
505
-
506
- beforeEach(() => {
507
- render(<TestComponent />);
508
- });
509
-
510
- afterEach(() => {
511
- vi.restoreAllMocks();
512
- cleanup();
513
- });
514
-
515
- it(
516
- 'allowEmptySubmit',
517
- withTestServer(
518
- [
519
- {
520
- url: '/api/chat',
521
- type: 'stream-values',
522
- content: ['Hello', ',', ' world', '.'],
523
- },
524
- {
525
- url: '/api/chat',
526
- type: 'stream-values',
527
- content: ['How', ' can', ' I', ' help', ' you', '?'],
528
- },
529
- {
530
- url: '/api/chat',
531
- type: 'stream-values',
532
- content: ['The', ' sky', ' is', ' blue', '.'],
533
- },
534
- ],
535
- async () => {
536
- const firstInput = screen.getByTestId('do-input');
537
- await userEvent.type(firstInput, 'hi');
538
- await userEvent.keyboard('{Enter}');
539
-
540
- await screen.findByTestId('message-0');
541
- expect(screen.getByTestId('message-0')).toHaveTextContent('User: hi');
542
-
543
- await screen.findByTestId('message-1');
544
- expect(screen.getByTestId('message-1')).toHaveTextContent(
545
- 'AI: Hello, world.',
546
- );
547
-
548
- const secondInput = screen.getByTestId('do-input');
549
- await userEvent.type(secondInput, '{Enter}');
550
-
551
- expect(screen.getByTestId('message-2')).toHaveTextContent(
552
- 'AI: How can I help you?',
553
- );
554
-
555
- const thirdInput = screen.getByTestId('do-input');
556
- await userEvent.type(thirdInput, 'what color is the sky?');
557
- await userEvent.type(thirdInput, '{Enter}');
558
-
559
- expect(screen.getByTestId('message-3')).toHaveTextContent(
560
- 'User: what color is the sky?',
561
- );
562
-
563
- await screen.findByTestId('message-4');
564
- expect(screen.getByTestId('message-4')).toHaveTextContent(
565
- 'AI: The sky is blue.',
566
- );
567
- },
568
- ),
569
- );
570
- });
571
-
572
- describe('prepareRequestBody', () => {
573
- let bodyOptions: any;
574
-
575
- const TestComponent = () => {
576
- const { messages, append, isLoading } = useChat({
577
- experimental_prepareRequestBody(options) {
578
- bodyOptions = options;
579
- return 'test-request-body';
580
- },
581
- });
582
-
583
- return (
584
- <div>
585
- <div data-testid="loading">{isLoading.toString()}</div>
586
- {messages.map((m, idx) => (
587
- <div data-testid={`message-${idx}`} key={m.id}>
588
- {m.role === 'user' ? 'User: ' : 'AI: '}
589
- {m.content}
590
- </div>
591
- ))}
592
-
593
- <button
594
- data-testid="do-append"
595
- onClick={() => {
596
- append(
597
- { role: 'user', content: 'hi' },
598
- {
599
- data: { 'test-data-key': 'test-data-value' },
600
- body: { 'request-body-key': 'request-body-value' },
601
- },
602
- );
603
- }}
604
- />
605
- </div>
606
- );
607
- };
608
-
609
- beforeEach(() => {
610
- render(<TestComponent />);
611
- });
612
-
613
- afterEach(() => {
614
- bodyOptions = undefined;
615
- vi.restoreAllMocks();
616
- cleanup();
617
- });
618
-
619
- it(
620
- 'should show streamed response',
621
- withTestServer(
622
- {
623
- url: '/api/chat',
624
- type: 'stream-values',
625
- content: ['0:"Hello"\n', '0:","\n', '0:" world"\n', '0:"."\n'],
626
- },
627
- async ({ call }) => {
628
- await userEvent.click(screen.getByTestId('do-append'));
629
-
630
- await screen.findByTestId('message-0');
631
- expect(screen.getByTestId('message-0')).toHaveTextContent('User: hi');
632
-
633
- expect(bodyOptions).toStrictEqual({
634
- messages: [{ role: 'user', content: 'hi', id: expect.any(String) }],
635
- requestData: { 'test-data-key': 'test-data-value' },
636
- requestBody: { 'request-body-key': 'request-body-value' },
637
- });
638
-
639
- expect(await call(0).getRequestBodyJson()).toBe('test-request-body');
640
-
641
- await screen.findByTestId('message-1');
642
- expect(screen.getByTestId('message-1')).toHaveTextContent(
643
- 'AI: Hello, world.',
644
- );
645
- },
646
- ),
647
- );
648
- });
649
-
650
- describe('onToolCall', () => {
651
- const TestComponent = () => {
652
- const { messages, append } = useChat({
653
- async onToolCall({ toolCall }) {
654
- return `test-tool-response: ${toolCall.toolName} ${
655
- toolCall.toolCallId
656
- } ${JSON.stringify(toolCall.args)}`;
657
- },
658
- });
659
-
660
- return (
661
- <div>
662
- {messages.map((m, idx) => (
663
- <div data-testid={`message-${idx}`} key={m.id}>
664
- {m.toolInvocations?.map((toolInvocation, toolIdx) =>
665
- 'result' in toolInvocation ? (
666
- <div key={toolIdx} data-testid={`tool-invocation-${toolIdx}`}>
667
- {toolInvocation.result}
668
- </div>
669
- ) : null,
670
- )}
671
- </div>
672
- ))}
673
-
674
- <button
675
- data-testid="do-append"
676
- onClick={() => {
677
- append({ role: 'user', content: 'hi' });
678
- }}
679
- />
680
- </div>
681
- );
682
- };
683
-
684
- beforeEach(() => {
685
- render(<TestComponent />);
686
- });
687
-
688
- afterEach(() => {
689
- vi.restoreAllMocks();
690
- cleanup();
691
- });
692
-
693
- it(
694
- "should invoke onToolCall when a tool call is received from the server's response",
695
- withTestServer(
696
- {
697
- url: '/api/chat',
698
- type: 'stream-values',
699
- content: [
700
- formatStreamPart('tool_call', {
701
- toolCallId: 'tool-call-0',
702
- toolName: 'test-tool',
703
- args: { testArg: 'test-value' },
704
- }),
705
- ],
706
- },
707
- async () => {
708
- await userEvent.click(screen.getByTestId('do-append'));
709
-
710
- await screen.findByTestId('message-1');
711
- expect(screen.getByTestId('message-1')).toHaveTextContent(
712
- 'test-tool-response: test-tool tool-call-0 {"testArg":"test-value"}',
713
- );
714
- },
715
- ),
716
- );
717
- });
718
-
719
- describe('tool invocations', () => {
720
- const TestComponent = () => {
721
- const { messages, append } = useChat();
722
-
723
- return (
724
- <div>
725
- {messages.map((m, idx) => (
726
- <div data-testid={`message-${idx}`} key={m.id}>
727
- {m.toolInvocations?.map((toolInvocation, toolIdx) => {
728
- return (
729
- <div key={toolIdx} data-testid={`tool-invocation-${toolIdx}`}>
730
- {JSON.stringify(toolInvocation)}
731
- </div>
732
- );
733
- })}
734
- </div>
735
- ))}
736
-
737
- <button
738
- data-testid="do-append"
739
- onClick={() => {
740
- append({ role: 'user', content: 'hi' });
741
- }}
742
- />
743
- </div>
744
- );
745
- };
746
-
747
- beforeEach(() => {
748
- render(<TestComponent />);
749
- });
750
-
751
- afterEach(() => {
752
- vi.restoreAllMocks();
753
- cleanup();
754
- });
755
-
756
- it(
757
- 'should display partial tool call, tool call, and tool result',
758
- withTestServer(
759
- { url: '/api/chat', type: 'controlled-stream' },
760
- async ({ streamController }) => {
761
- await userEvent.click(screen.getByTestId('do-append'));
762
-
763
- streamController.enqueue(
764
- formatStreamPart('tool_call_streaming_start', {
765
- toolCallId: 'tool-call-0',
766
- toolName: 'test-tool',
767
- }),
768
- );
769
-
770
- await waitFor(() => {
771
- expect(screen.getByTestId('message-1')).toHaveTextContent(
772
- '{"state":"partial-call","toolCallId":"tool-call-0","toolName":"test-tool"}',
773
- );
774
- });
775
-
776
- streamController.enqueue(
777
- formatStreamPart('tool_call_delta', {
778
- toolCallId: 'tool-call-0',
779
- argsTextDelta: '{"testArg":"t',
780
- }),
781
- );
782
-
783
- await waitFor(() => {
784
- expect(screen.getByTestId('message-1')).toHaveTextContent(
785
- '{"state":"partial-call","toolCallId":"tool-call-0","toolName":"test-tool","args":{"testArg":"t"}}',
786
- );
787
- });
788
-
789
- streamController.enqueue(
790
- formatStreamPart('tool_call_delta', {
791
- toolCallId: 'tool-call-0',
792
- argsTextDelta: 'est-value"}}',
793
- }),
794
- );
795
-
796
- await waitFor(() => {
797
- expect(screen.getByTestId('message-1')).toHaveTextContent(
798
- '{"state":"partial-call","toolCallId":"tool-call-0","toolName":"test-tool","args":{"testArg":"test-value"}}',
799
- );
800
- });
801
-
802
- streamController.enqueue(
803
- formatStreamPart('tool_call', {
804
- toolCallId: 'tool-call-0',
805
- toolName: 'test-tool',
806
- args: { testArg: 'test-value' },
807
- }),
808
- );
809
-
810
- await waitFor(() => {
811
- expect(screen.getByTestId('message-1')).toHaveTextContent(
812
- '{"state":"call","toolCallId":"tool-call-0","toolName":"test-tool","args":{"testArg":"test-value"}}',
813
- );
814
- });
815
-
816
- streamController.enqueue(
817
- formatStreamPart('tool_result', {
818
- toolCallId: 'tool-call-0',
819
- result: 'test-result',
820
- }),
821
- );
822
- streamController.close();
823
-
824
- await waitFor(() => {
825
- expect(screen.getByTestId('message-1')).toHaveTextContent(
826
- '{"state":"result","toolCallId":"tool-call-0","toolName":"test-tool","args":{"testArg":"test-value"},"result":"test-result"}',
827
- );
828
- });
829
- },
830
- ),
831
- );
832
-
833
- it(
834
- 'should display partial tool call and tool result (when there is no tool call streaming)',
835
- withTestServer(
836
- { url: '/api/chat', type: 'controlled-stream' },
837
- async ({ streamController }) => {
838
- await userEvent.click(screen.getByTestId('do-append'));
839
-
840
- streamController.enqueue(
841
- formatStreamPart('tool_call', {
842
- toolCallId: 'tool-call-0',
843
- toolName: 'test-tool',
844
- args: { testArg: 'test-value' },
845
- }),
846
- );
847
-
848
- await waitFor(() => {
849
- expect(screen.getByTestId('message-1')).toHaveTextContent(
850
- '{"state":"call","toolCallId":"tool-call-0","toolName":"test-tool","args":{"testArg":"test-value"}}',
851
- );
852
- });
853
-
854
- streamController.enqueue(
855
- formatStreamPart('tool_result', {
856
- toolCallId: 'tool-call-0',
857
- result: 'test-result',
858
- }),
859
- );
860
- streamController.close();
861
-
862
- await waitFor(() => {
863
- expect(screen.getByTestId('message-1')).toHaveTextContent(
864
- '{"state":"result","toolCallId":"tool-call-0","toolName":"test-tool","args":{"testArg":"test-value"},"result":"test-result"}',
865
- );
866
- });
867
- },
868
- ),
869
- );
870
- });
871
-
872
- describe('maxToolRoundtrips', () => {
873
- describe('single automatic tool roundtrip', () => {
874
- let onToolCallInvoked = false;
875
-
876
- const TestComponent = () => {
877
- const { messages, append } = useChat({
878
- async onToolCall({ toolCall }) {
879
- onToolCallInvoked = true;
880
-
881
- return `test-tool-response: ${toolCall.toolName} ${
882
- toolCall.toolCallId
883
- } ${JSON.stringify(toolCall.args)}`;
884
- },
885
- maxToolRoundtrips: 5,
886
- });
887
-
888
- return (
889
- <div>
890
- {messages.map((m, idx) => (
891
- <div data-testid={`message-${idx}`} key={m.id}>
892
- {m.content}
893
- </div>
894
- ))}
895
-
896
- <button
897
- data-testid="do-append"
898
- onClick={() => {
899
- append({ role: 'user', content: 'hi' });
900
- }}
901
- />
902
- </div>
903
- );
904
- };
905
-
906
- beforeEach(() => {
907
- render(<TestComponent />);
908
- onToolCallInvoked = false;
909
- });
910
-
911
- afterEach(() => {
912
- vi.restoreAllMocks();
913
- cleanup();
914
- });
915
-
916
- it(
917
- 'should automatically call api when tool call gets executed via onToolCall',
918
- withTestServer(
919
- [
920
- {
921
- url: '/api/chat',
922
- type: 'stream-values',
923
- content: [
924
- formatStreamPart('tool_call', {
925
- toolCallId: 'tool-call-0',
926
- toolName: 'test-tool',
927
- args: { testArg: 'test-value' },
928
- }),
929
- ],
930
- },
931
- {
932
- url: '/api/chat',
933
- type: 'stream-values',
934
- content: [formatStreamPart('text', 'final result')],
935
- },
936
- ],
937
- async () => {
938
- await userEvent.click(screen.getByTestId('do-append'));
939
-
940
- expect(onToolCallInvoked).toBe(true);
941
-
942
- await screen.findByTestId('message-2');
943
- expect(screen.getByTestId('message-2')).toHaveTextContent(
944
- 'final result',
945
- );
946
- },
947
- ),
948
- );
949
- });
950
-
951
- describe('single roundtrip with error response', () => {
952
- let onToolCallCounter = 0;
953
-
954
- const TestComponent = () => {
955
- const { messages, append, error } = useChat({
956
- async onToolCall({ toolCall }) {
957
- onToolCallCounter++;
958
- return `test-tool-response: ${toolCall.toolName} ${
959
- toolCall.toolCallId
960
- } ${JSON.stringify(toolCall.args)}`;
961
- },
962
- maxToolRoundtrips: 5,
963
- });
964
-
965
- return (
966
- <div>
967
- {error && <div data-testid="error">{error.toString()}</div>}
968
-
969
- {messages.map((m, idx) => (
970
- <div data-testid={`message-${idx}`} key={m.id}>
971
- {m.toolInvocations?.map((toolInvocation, toolIdx) =>
972
- 'result' in toolInvocation ? (
973
- <div key={toolIdx} data-testid={`tool-invocation-${toolIdx}`}>
974
- {toolInvocation.result}
975
- </div>
976
- ) : null,
977
- )}
978
- </div>
979
- ))}
980
-
981
- <button
982
- data-testid="do-append"
983
- onClick={() => {
984
- append({ role: 'user', content: 'hi' });
985
- }}
986
- />
987
- </div>
988
- );
989
- };
990
-
991
- beforeEach(() => {
992
- render(<TestComponent />);
993
- onToolCallCounter = 0;
994
- });
995
-
996
- afterEach(() => {
997
- vi.restoreAllMocks();
998
- cleanup();
999
- });
1000
-
1001
- it(
1002
- 'should automatically call api when tool call gets executed via onToolCall',
1003
- withTestServer(
1004
- [
1005
- {
1006
- url: '/api/chat',
1007
- type: 'stream-values',
1008
- content: [
1009
- formatStreamPart('tool_call', {
1010
- toolCallId: 'tool-call-0',
1011
- toolName: 'test-tool',
1012
- args: { testArg: 'test-value' },
1013
- }),
1014
- ],
1015
- },
1016
- {
1017
- url: '/api/chat',
1018
- type: 'error',
1019
- status: 400,
1020
- content: 'call failure',
1021
- },
1022
- ],
1023
- async () => {
1024
- await userEvent.click(screen.getByTestId('do-append'));
1025
-
1026
- await screen.findByTestId('error');
1027
- expect(screen.getByTestId('error')).toHaveTextContent(
1028
- 'Error: call failure',
1029
- );
1030
-
1031
- expect(onToolCallCounter).toBe(1);
1032
- },
1033
- ),
1034
- );
1035
- });
1036
- });
1037
-
1038
- describe('file attachments with data url', () => {
1039
- const TestComponent = () => {
1040
- const { messages, handleSubmit, handleInputChange, isLoading, input } =
1041
- useChat();
1042
-
1043
- const [attachments, setAttachments] = useState<FileList | undefined>(
1044
- undefined,
1045
- );
1046
- const fileInputRef = useRef<HTMLInputElement>(null);
1047
-
1048
- return (
1049
- <div>
1050
- {messages.map((m, idx) => (
1051
- <div data-testid={`message-${idx}`} key={m.id}>
1052
- {m.role === 'user' ? 'User: ' : 'AI: '}
1053
- {m.content}
1054
- {m.experimental_attachments?.map(attachment => {
1055
- if (attachment.contentType?.startsWith('image/')) {
1056
- return (
1057
- <img
1058
- key={attachment.name}
1059
- src={attachment.url}
1060
- alt={attachment.name}
1061
- data-testid={`attachment-${idx}`}
1062
- />
1063
- );
1064
- } else if (attachment.contentType?.startsWith('text/')) {
1065
- return (
1066
- <div key={attachment.name} data-testid={`attachment-${idx}`}>
1067
- {getTextFromDataUrl(attachment.url)}
1068
- </div>
1069
- );
1070
- }
1071
- })}
1072
- </div>
1073
- ))}
1074
-
1075
- <form
1076
- onSubmit={event => {
1077
- handleSubmit(event, {
1078
- experimental_attachments: attachments,
1079
- });
1080
- setAttachments(undefined);
1081
- if (fileInputRef.current) {
1082
- fileInputRef.current.value = '';
1083
- }
1084
- }}
1085
- data-testid="chat-form"
1086
- >
1087
- <input
1088
- type="file"
1089
- onChange={event => {
1090
- if (event.target.files) {
1091
- setAttachments(event.target.files);
1092
- }
1093
- }}
1094
- multiple
1095
- ref={fileInputRef}
1096
- data-testid="file-input"
1097
- />
1098
- <input
1099
- value={input}
1100
- onChange={handleInputChange}
1101
- disabled={isLoading}
1102
- data-testid="message-input"
1103
- />
1104
- <button type="submit" data-testid="submit-button">
1105
- Send
1106
- </button>
1107
- </form>
1108
- </div>
1109
- );
1110
- };
1111
-
1112
- beforeEach(() => {
1113
- render(<TestComponent />);
1114
- });
1115
-
1116
- afterEach(() => {
1117
- vi.restoreAllMocks();
1118
- cleanup();
1119
- });
1120
-
1121
- it(
1122
- 'should handle text file attachment and submission',
1123
- withTestServer(
1124
- {
1125
- url: '/api/chat',
1126
- type: 'stream-values',
1127
- content: ['0:"Response to message with text attachment"\n'],
1128
- },
1129
- async ({ call }) => {
1130
- const file = new File(['test file content'], 'test.txt', {
1131
- type: 'text/plain',
1132
- });
1133
-
1134
- const fileInput = screen.getByTestId('file-input');
1135
- await userEvent.upload(fileInput, file);
1136
-
1137
- const messageInput = screen.getByTestId('message-input');
1138
- await userEvent.type(messageInput, 'Message with text attachment');
1139
-
1140
- const submitButton = screen.getByTestId('submit-button');
1141
- await userEvent.click(submitButton);
1142
-
1143
- await screen.findByTestId('message-0');
1144
- expect(screen.getByTestId('message-0')).toHaveTextContent(
1145
- 'User: Message with text attachment',
1146
- );
1147
-
1148
- await screen.findByTestId('attachment-0');
1149
- expect(screen.getByTestId('attachment-0')).toHaveTextContent(
1150
- 'test file content',
1151
- );
1152
-
1153
- await screen.findByTestId('message-1');
1154
- expect(screen.getByTestId('message-1')).toHaveTextContent(
1155
- 'AI: Response to message with text attachment',
1156
- );
1157
-
1158
- expect(await call(0).getRequestBodyJson()).toStrictEqual({
1159
- messages: [
1160
- {
1161
- role: 'user',
1162
- content: 'Message with text attachment',
1163
- experimental_attachments: [
1164
- {
1165
- name: 'test.txt',
1166
- contentType: 'text/plain',
1167
- url: 'data:text/plain;base64,dGVzdCBmaWxlIGNvbnRlbnQ=',
1168
- },
1169
- ],
1170
- },
1171
- ],
1172
- });
1173
- },
1174
- ),
1175
- );
1176
-
1177
- it(
1178
- 'should handle image file attachment and submission',
1179
- withTestServer(
1180
- {
1181
- url: '/api/chat',
1182
- type: 'stream-values',
1183
- content: ['0:"Response to message with image attachment"\n'],
1184
- },
1185
- async ({ call }) => {
1186
- const file = new File(['test image content'], 'test.png', {
1187
- type: 'image/png',
1188
- });
1189
-
1190
- const fileInput = screen.getByTestId('file-input');
1191
- await userEvent.upload(fileInput, file);
1192
-
1193
- const messageInput = screen.getByTestId('message-input');
1194
- await userEvent.type(messageInput, 'Message with image attachment');
1195
-
1196
- const submitButton = screen.getByTestId('submit-button');
1197
- await userEvent.click(submitButton);
1198
-
1199
- await screen.findByTestId('message-0');
1200
- expect(screen.getByTestId('message-0')).toHaveTextContent(
1201
- 'User: Message with image attachment',
1202
- );
1203
-
1204
- await screen.findByTestId('attachment-0');
1205
- expect(screen.getByTestId('attachment-0')).toHaveAttribute(
1206
- 'src',
1207
- expect.stringContaining('data:image/png;base64'),
1208
- );
1209
-
1210
- await screen.findByTestId('message-1');
1211
- expect(screen.getByTestId('message-1')).toHaveTextContent(
1212
- 'AI: Response to message with image attachment',
1213
- );
1214
-
1215
- expect(await call(0).getRequestBodyJson()).toStrictEqual({
1216
- messages: [
1217
- {
1218
- role: 'user',
1219
- content: 'Message with image attachment',
1220
- experimental_attachments: [
1221
- {
1222
- name: 'test.png',
1223
- contentType: 'image/png',
1224
- url: 'data:image/png;base64,dGVzdCBpbWFnZSBjb250ZW50',
1225
- },
1226
- ],
1227
- },
1228
- ],
1229
- });
1230
- },
1231
- ),
1232
- );
1233
- });
1234
-
1235
- describe('file attachments with url', () => {
1236
- const TestComponent = () => {
1237
- const { messages, handleSubmit, handleInputChange, isLoading, input } =
1238
- useChat();
1239
-
1240
- return (
1241
- <div>
1242
- {messages.map((m, idx) => (
1243
- <div data-testid={`message-${idx}`} key={m.id}>
1244
- {m.role === 'user' ? 'User: ' : 'AI: '}
1245
- {m.content}
1246
- {m.experimental_attachments?.map(attachment => {
1247
- if (attachment.contentType?.startsWith('image/')) {
1248
- return (
1249
- <img
1250
- key={attachment.name}
1251
- src={attachment.url}
1252
- alt={attachment.name}
1253
- data-testid={`attachment-${idx}`}
1254
- />
1255
- );
1256
- } else if (attachment.contentType?.startsWith('text/')) {
1257
- return (
1258
- <div key={attachment.name} data-testid={`attachment-${idx}`}>
1259
- {Buffer.from(
1260
- attachment.url.split(',')[1],
1261
- 'base64',
1262
- ).toString('utf-8')}
1263
- </div>
1264
- );
1265
- }
1266
- })}
1267
- </div>
1268
- ))}
1269
-
1270
- <form
1271
- onSubmit={event => {
1272
- handleSubmit(event, {
1273
- experimental_attachments: [
1274
- {
1275
- name: 'test.png',
1276
- contentType: 'image/png',
1277
- url: 'https://example.com/image.png',
1278
- },
1279
- ],
1280
- });
1281
- }}
1282
- data-testid="chat-form"
1283
- >
1284
- <input
1285
- value={input}
1286
- onChange={handleInputChange}
1287
- disabled={isLoading}
1288
- data-testid="message-input"
1289
- />
1290
- <button type="submit" data-testid="submit-button">
1291
- Send
1292
- </button>
1293
- </form>
1294
- </div>
1295
- );
1296
- };
1297
-
1298
- beforeEach(() => {
1299
- render(<TestComponent />);
1300
- });
1301
-
1302
- afterEach(() => {
1303
- vi.restoreAllMocks();
1304
- cleanup();
1305
- });
1306
-
1307
- it(
1308
- 'should handle image file attachment and submission',
1309
- withTestServer(
1310
- {
1311
- url: '/api/chat',
1312
- type: 'stream-values',
1313
- content: ['0:"Response to message with image attachment"\n'],
1314
- },
1315
- async ({ call }) => {
1316
- const messageInput = screen.getByTestId('message-input');
1317
- await userEvent.type(messageInput, 'Message with image attachment');
1318
-
1319
- const submitButton = screen.getByTestId('submit-button');
1320
- await userEvent.click(submitButton);
1321
-
1322
- await screen.findByTestId('message-0');
1323
- expect(screen.getByTestId('message-0')).toHaveTextContent(
1324
- 'User: Message with image attachment',
1325
- );
1326
-
1327
- await screen.findByTestId('attachment-0');
1328
- expect(screen.getByTestId('attachment-0')).toHaveAttribute(
1329
- 'src',
1330
- expect.stringContaining('https://example.com/image.png'),
1331
- );
1332
-
1333
- await screen.findByTestId('message-1');
1334
- expect(screen.getByTestId('message-1')).toHaveTextContent(
1335
- 'AI: Response to message with image attachment',
1336
- );
1337
-
1338
- expect(await call(0).getRequestBodyJson()).toStrictEqual({
1339
- messages: [
1340
- {
1341
- role: 'user',
1342
- content: 'Message with image attachment',
1343
- experimental_attachments: [
1344
- {
1345
- name: 'test.png',
1346
- contentType: 'image/png',
1347
- url: 'https://example.com/image.png',
1348
- },
1349
- ],
1350
- },
1351
- ],
1352
- });
1353
- },
1354
- ),
1355
- );
1356
- });
1357
-
1358
- describe('attachments with empty submit', () => {
1359
- const TestComponent = () => {
1360
- const { messages, handleSubmit } = useChat();
1361
-
1362
- return (
1363
- <div>
1364
- {messages.map((m, idx) => (
1365
- <div data-testid={`message-${idx}`} key={m.id}>
1366
- {m.role === 'user' ? 'User: ' : 'AI: '}
1367
- {m.content}
1368
- {m.experimental_attachments?.map(attachment => (
1369
- <img
1370
- key={attachment.name}
1371
- src={attachment.url}
1372
- alt={attachment.name}
1373
- data-testid={`attachment-${idx}`}
1374
- />
1375
- ))}
1376
- </div>
1377
- ))}
1378
-
1379
- <form
1380
- onSubmit={event => {
1381
- handleSubmit(event, {
1382
- allowEmptySubmit: true,
1383
- experimental_attachments: [
1384
- {
1385
- name: 'test.png',
1386
- contentType: 'image/png',
1387
- url: 'https://example.com/image.png',
1388
- },
1389
- ],
1390
- });
1391
- }}
1392
- data-testid="chat-form"
1393
- >
1394
- <button type="submit" data-testid="submit-button">
1395
- Send
1396
- </button>
1397
- </form>
1398
- </div>
1399
- );
1400
- };
1401
-
1402
- beforeEach(() => {
1403
- render(<TestComponent />);
1404
- });
1405
-
1406
- afterEach(() => {
1407
- vi.restoreAllMocks();
1408
- cleanup();
1409
- });
1410
-
1411
- it(
1412
- 'should handle image file attachment and submission',
1413
- withTestServer(
1414
- {
1415
- url: '/api/chat',
1416
- type: 'stream-values',
1417
- content: ['0:"Response to message with image attachment"\n'],
1418
- },
1419
- async ({ call }) => {
1420
- const submitButton = screen.getByTestId('submit-button');
1421
- await userEvent.click(submitButton);
1422
-
1423
- await screen.findByTestId('message-0');
1424
- expect(screen.getByTestId('message-0')).toHaveTextContent('User:');
1425
-
1426
- await screen.findByTestId('attachment-0');
1427
- expect(screen.getByTestId('attachment-0')).toHaveAttribute(
1428
- 'src',
1429
- expect.stringContaining('https://example.com/image.png'),
1430
- );
1431
-
1432
- await screen.findByTestId('message-1');
1433
- expect(screen.getByTestId('message-1')).toHaveTextContent('AI:');
1434
-
1435
- expect(await call(0).getRequestBodyJson()).toStrictEqual({
1436
- messages: [
1437
- {
1438
- role: 'user',
1439
- content: '',
1440
- experimental_attachments: [
1441
- {
1442
- name: 'test.png',
1443
- contentType: 'image/png',
1444
- url: 'https://example.com/image.png',
1445
- },
1446
- ],
1447
- },
1448
- ],
1449
- });
1450
- },
1451
- ),
1452
- );
1453
- });
1454
-
1455
- describe('reload', () => {
1456
- const TestComponent = () => {
1457
- const { messages, append, reload } = useChat();
1458
-
1459
- return (
1460
- <div>
1461
- {messages.map((m, idx) => (
1462
- <div data-testid={`message-${idx}`} key={m.id}>
1463
- {m.role === 'user' ? 'User: ' : 'AI: '}
1464
- {m.content}
1465
- </div>
1466
- ))}
1467
-
1468
- <button
1469
- data-testid="do-append"
1470
- onClick={() => {
1471
- append({ role: 'user', content: 'hi' });
1472
- }}
1473
- />
1474
-
1475
- <button
1476
- data-testid="do-reload"
1477
- onClick={() => {
1478
- reload({
1479
- data: { 'test-data-key': 'test-data-value' },
1480
- body: { 'request-body-key': 'request-body-value' },
1481
- headers: { 'header-key': 'header-value' },
1482
- });
1483
- }}
1484
- />
1485
- </div>
1486
- );
1487
- };
1488
-
1489
- beforeEach(() => {
1490
- render(<TestComponent />);
1491
- });
1492
-
1493
- afterEach(() => {
1494
- vi.restoreAllMocks();
1495
- cleanup();
1496
- });
1497
-
1498
- it(
1499
- 'should show streamed response',
1500
- withTestServer(
1501
- [
1502
- {
1503
- url: '/api/chat',
1504
- type: 'stream-values',
1505
- content: ['0:"first response"\n'],
1506
- },
1507
- {
1508
- url: '/api/chat',
1509
- type: 'stream-values',
1510
- content: ['0:"second response"\n'],
1511
- },
1512
- ],
1513
- async ({ call }) => {
1514
- await userEvent.click(screen.getByTestId('do-append'));
1515
-
1516
- await screen.findByTestId('message-0');
1517
- expect(screen.getByTestId('message-0')).toHaveTextContent('User: hi');
1518
-
1519
- await screen.findByTestId('message-1');
1520
-
1521
- // setup done, click reload:
1522
- await userEvent.click(screen.getByTestId('do-reload'));
1523
-
1524
- expect(await call(1).getRequestBodyJson()).toStrictEqual({
1525
- messages: [{ content: 'hi', role: 'user' }],
1526
- data: { 'test-data-key': 'test-data-value' },
1527
- 'request-body-key': 'request-body-value',
1528
- });
1529
-
1530
- expect(call(1).getRequestHeaders()).toStrictEqual({
1531
- 'content-type': 'application/json',
1532
- 'header-key': 'header-value',
1533
- });
1534
-
1535
- await screen.findByTestId('message-1');
1536
- expect(screen.getByTestId('message-1')).toHaveTextContent(
1537
- 'AI: second response',
1538
- );
1539
- },
1540
- ),
1541
- );
1542
- });