@ai-sdk/react 3.0.48 → 3.0.50

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,2532 +0,0 @@
1
- /* eslint-disable jsx-a11y/alt-text */
2
- /* eslint-disable @next/next/no-img-element */
3
- import {
4
- createTestServer,
5
- TestResponseController,
6
- } from '@ai-sdk/test-server/with-vitest';
7
- import { mockId } from '@ai-sdk/provider-utils/test';
8
- import '@testing-library/jest-dom/vitest';
9
- import { screen, waitFor, render } from '@testing-library/react';
10
- import userEvent from '@testing-library/user-event';
11
- import {
12
- DefaultChatTransport,
13
- FinishReason,
14
- isStaticToolUIPart,
15
- TextStreamChatTransport,
16
- UIMessage,
17
- UIMessageChunk,
18
- } from 'ai';
19
- import React, { act, useRef, useState } from 'react';
20
- import { Chat } from './chat.react';
21
- import { setupTestComponent } from './setup-test-component';
22
- import { useChat } from './use-chat';
23
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
24
-
25
- function formatChunk(part: UIMessageChunk) {
26
- return `data: ${JSON.stringify(part)}\n\n`;
27
- }
28
-
29
- const server = createTestServer({
30
- '/api/chat': {},
31
- '/api/chat/123/stream': {},
32
- });
33
-
34
- describe('initial messages', () => {
35
- setupTestComponent(
36
- ({ id: idParam }: { id: string }) => {
37
- const [id, setId] = React.useState<string>(idParam);
38
- const {
39
- messages,
40
- status,
41
- id: idKey,
42
- } = useChat({
43
- id,
44
- messages: [
45
- {
46
- id: 'id-0',
47
- role: 'user',
48
- parts: [{ text: 'hi', type: 'text' }],
49
- },
50
- ],
51
- });
52
-
53
- return (
54
- <div>
55
- <div data-testid="id">{idKey}</div>
56
- <div data-testid="status">{status.toString()}</div>
57
- <div data-testid="messages">{JSON.stringify(messages, null, 2)}</div>
58
- </div>
59
- );
60
- },
61
- {
62
- // use a random id to avoid conflicts:
63
- init: TestComponent => <TestComponent id={`first-${mockId()()}`} />,
64
- },
65
- );
66
-
67
- it('should show initial messages', async () => {
68
- await waitFor(() => {
69
- expect(
70
- JSON.parse(screen.getByTestId('messages').textContent ?? ''),
71
- ).toStrictEqual([
72
- {
73
- role: 'user',
74
- parts: [
75
- {
76
- text: 'hi',
77
- type: 'text',
78
- },
79
- ],
80
- id: 'id-0',
81
- },
82
- ]);
83
- });
84
- });
85
- });
86
-
87
- describe('data protocol stream', () => {
88
- let onFinishCalls: Array<{
89
- message: UIMessage;
90
- messages: UIMessage[];
91
- isAbort: boolean;
92
- isDisconnect: boolean;
93
- isError: boolean;
94
- finishReason?: FinishReason;
95
- }> = [];
96
-
97
- setupTestComponent(
98
- ({ id: idParam }: { id: string }) => {
99
- const [id, setId] = React.useState<string>(idParam);
100
- const {
101
- messages,
102
- sendMessage,
103
- error,
104
- status,
105
- id: idKey,
106
- } = useChat({
107
- id,
108
- onFinish: options => {
109
- onFinishCalls.push(options);
110
- },
111
- generateId: mockId(),
112
- });
113
-
114
- return (
115
- <div>
116
- <div data-testid="id">{idKey}</div>
117
- <div data-testid="status">{status.toString()}</div>
118
- {error && <div data-testid="error">{error.toString()}</div>}
119
- <div data-testid="messages">{JSON.stringify(messages, null, 2)}</div>
120
- <button
121
- data-testid="do-send"
122
- onClick={() => {
123
- sendMessage({ parts: [{ text: 'hi', type: 'text' }] });
124
- }}
125
- />
126
- <button
127
- data-testid="do-change-id"
128
- onClick={() => {
129
- setId('second-id');
130
- }}
131
- />
132
- </div>
133
- );
134
- },
135
- {
136
- // use a random id to avoid conflicts:
137
- init: TestComponent => <TestComponent id={`first-${mockId()()}`} />,
138
- },
139
- );
140
-
141
- beforeEach(() => {
142
- onFinishCalls = [];
143
- });
144
-
145
- it('should show streamed response', async () => {
146
- server.urls['/api/chat'].response = {
147
- type: 'stream-chunks',
148
- chunks: [
149
- formatChunk({ type: 'text-start', id: '0' }),
150
- formatChunk({ type: 'text-delta', id: '0', delta: 'Hello' }),
151
- formatChunk({ type: 'text-delta', id: '0', delta: ',' }),
152
- formatChunk({ type: 'text-delta', id: '0', delta: ' world' }),
153
- formatChunk({ type: 'text-delta', id: '0', delta: '.' }),
154
- formatChunk({ type: 'text-end', id: '0' }),
155
- ],
156
- };
157
-
158
- await userEvent.click(screen.getByTestId('do-send'));
159
-
160
- await waitFor(() => {
161
- expect(
162
- JSON.parse(screen.getByTestId('messages').textContent ?? ''),
163
- ).toStrictEqual([
164
- {
165
- role: 'user',
166
- parts: [
167
- {
168
- text: 'hi',
169
- type: 'text',
170
- },
171
- ],
172
- id: 'id-0',
173
- },
174
- {
175
- id: 'id-1',
176
- role: 'assistant',
177
- parts: [
178
- {
179
- type: 'text',
180
- text: 'Hello, world.',
181
- state: 'done',
182
- },
183
- ],
184
- },
185
- ]);
186
- });
187
- });
188
-
189
- it('should show user message immediately', async () => {
190
- const controller = new TestResponseController();
191
- server.urls['/api/chat'].response = {
192
- type: 'controlled-stream',
193
- controller,
194
- };
195
-
196
- await userEvent.click(screen.getByTestId('do-send'));
197
-
198
- await waitFor(() => {
199
- expect(
200
- JSON.parse(screen.getByTestId('messages').textContent ?? ''),
201
- ).toStrictEqual([
202
- {
203
- role: 'user',
204
- parts: [
205
- {
206
- text: 'hi',
207
- type: 'text',
208
- },
209
- ],
210
- id: 'id-0',
211
- },
212
- ]);
213
- });
214
- });
215
-
216
- it('should show error response when there is a server error', async () => {
217
- server.urls['/api/chat'].response = {
218
- type: 'error',
219
- status: 404,
220
- body: 'Not found',
221
- };
222
-
223
- await userEvent.click(screen.getByTestId('do-send'));
224
-
225
- await screen.findByTestId('error');
226
- expect(screen.getByTestId('error')).toHaveTextContent('Error: Not found');
227
- });
228
-
229
- it('should show error response when there is a streaming error', async () => {
230
- server.urls['/api/chat'].response = {
231
- type: 'stream-chunks',
232
- chunks: [
233
- formatChunk({ type: 'error', errorText: 'custom error message' }),
234
- ],
235
- };
236
-
237
- await userEvent.click(screen.getByTestId('do-send'));
238
-
239
- await screen.findByTestId('error');
240
- expect(screen.getByTestId('error')).toHaveTextContent(
241
- 'Error: custom error message',
242
- );
243
- });
244
-
245
- describe('status', () => {
246
- it('should show status', async () => {
247
- const controller = new TestResponseController();
248
-
249
- server.urls['/api/chat'].response = {
250
- type: 'controlled-stream',
251
- controller,
252
- };
253
-
254
- await userEvent.click(screen.getByTestId('do-send'));
255
-
256
- await waitFor(() => {
257
- expect(screen.getByTestId('status')).toHaveTextContent('submitted');
258
- });
259
-
260
- controller.write(formatChunk({ type: 'text-start', id: '0' }));
261
- controller.write(
262
- formatChunk({ type: 'text-delta', id: '0', delta: 'Hello' }),
263
- );
264
- controller.write(formatChunk({ type: 'text-end', id: '0' }));
265
-
266
- await waitFor(() => {
267
- expect(screen.getByTestId('status')).toHaveTextContent('streaming');
268
- });
269
-
270
- controller.close();
271
-
272
- await waitFor(() => {
273
- expect(screen.getByTestId('status')).toHaveTextContent('ready');
274
- });
275
- });
276
-
277
- it('should set status to error when there is a server error', async () => {
278
- server.urls['/api/chat'].response = {
279
- type: 'error',
280
- status: 404,
281
- body: 'Not found',
282
- };
283
-
284
- await userEvent.click(screen.getByTestId('do-send'));
285
-
286
- await waitFor(() => {
287
- expect(screen.getByTestId('status')).toHaveTextContent('error');
288
- });
289
- });
290
- });
291
-
292
- it('should invoke onFinish when the stream finishes', async () => {
293
- const controller = new TestResponseController();
294
-
295
- server.urls['/api/chat'].response = {
296
- type: 'controlled-stream',
297
- controller,
298
- };
299
-
300
- await userEvent.click(screen.getByTestId('do-send'));
301
-
302
- controller.write(formatChunk({ type: 'text-start', id: '0' }));
303
- controller.write(
304
- formatChunk({ type: 'text-delta', id: '0', delta: 'Hello' }),
305
- );
306
- controller.write(formatChunk({ type: 'text-delta', id: '0', delta: ',' }));
307
- controller.write(
308
- formatChunk({ type: 'text-delta', id: '0', delta: ' world' }),
309
- );
310
- controller.write(formatChunk({ type: 'text-delta', id: '0', delta: '.' }));
311
- controller.write(formatChunk({ type: 'text-end', id: '0' }));
312
- controller.write(
313
- formatChunk({
314
- type: 'finish',
315
- finishReason: 'stop',
316
- messageMetadata: {
317
- example: 'metadata',
318
- },
319
- }),
320
- );
321
-
322
- controller.close();
323
-
324
- await waitFor(() => {
325
- expect(
326
- JSON.parse(screen.getByTestId('messages').textContent ?? ''),
327
- ).toStrictEqual([
328
- {
329
- role: 'user',
330
- parts: [
331
- {
332
- text: 'hi',
333
- type: 'text',
334
- },
335
- ],
336
- id: 'id-0',
337
- },
338
- {
339
- id: 'id-1',
340
- role: 'assistant',
341
- metadata: {
342
- example: 'metadata',
343
- },
344
- parts: [
345
- {
346
- type: 'text',
347
- text: 'Hello, world.',
348
- state: 'done',
349
- },
350
- ],
351
- },
352
- ]);
353
- });
354
-
355
- expect(onFinishCalls).toMatchInlineSnapshot(`
356
- [
357
- {
358
- "finishReason": "stop",
359
- "isAbort": false,
360
- "isDisconnect": false,
361
- "isError": false,
362
- "message": {
363
- "id": "id-1",
364
- "metadata": {
365
- "example": "metadata",
366
- },
367
- "parts": [
368
- {
369
- "providerMetadata": undefined,
370
- "state": "done",
371
- "text": "Hello, world.",
372
- "type": "text",
373
- },
374
- ],
375
- "role": "assistant",
376
- },
377
- "messages": [
378
- {
379
- "id": "id-0",
380
- "metadata": undefined,
381
- "parts": [
382
- {
383
- "text": "hi",
384
- "type": "text",
385
- },
386
- ],
387
- "role": "user",
388
- },
389
- {
390
- "id": "id-1",
391
- "metadata": {
392
- "example": "metadata",
393
- },
394
- "parts": [
395
- {
396
- "providerMetadata": undefined,
397
- "state": "done",
398
- "text": "Hello, world.",
399
- "type": "text",
400
- },
401
- ],
402
- "role": "assistant",
403
- },
404
- ],
405
- },
406
- ]
407
- `);
408
- });
409
-
410
- describe('id', () => {
411
- it('send the id to the server', async () => {
412
- server.urls['/api/chat'].response = {
413
- type: 'stream-chunks',
414
- chunks: [
415
- formatChunk({ type: 'text-start', id: '0' }),
416
- formatChunk({ type: 'text-delta', id: '0', delta: 'Hello' }),
417
- formatChunk({ type: 'text-delta', id: '0', delta: ',' }),
418
- formatChunk({ type: 'text-delta', id: '0', delta: ' world' }),
419
- formatChunk({ type: 'text-delta', id: '0', delta: '.' }),
420
- formatChunk({ type: 'text-end', id: '0' }),
421
- ],
422
- };
423
-
424
- await userEvent.click(screen.getByTestId('do-send'));
425
-
426
- expect(await server.calls[0].requestBodyJson).toMatchInlineSnapshot(`
427
- {
428
- "id": "first-id-0",
429
- "messages": [
430
- {
431
- "id": "id-0",
432
- "parts": [
433
- {
434
- "text": "hi",
435
- "type": "text",
436
- },
437
- ],
438
- "role": "user",
439
- },
440
- ],
441
- "trigger": "submit-message",
442
- }
443
- `);
444
- });
445
- });
446
- });
447
-
448
- describe('text stream', () => {
449
- let onFinishCalls: Array<{
450
- message: UIMessage;
451
- messages: UIMessage[];
452
- isAbort: boolean;
453
- isDisconnect: boolean;
454
- isError: boolean;
455
- finishReason?: FinishReason;
456
- }> = [];
457
-
458
- setupTestComponent(() => {
459
- const { messages, sendMessage } = useChat({
460
- onFinish: options => {
461
- onFinishCalls.push(options);
462
- },
463
- generateId: mockId(),
464
- transport: new TextStreamChatTransport({
465
- api: '/api/chat',
466
- }),
467
- });
468
-
469
- return (
470
- <div>
471
- {messages.map((m, idx) => (
472
- <div data-testid={`message-${idx}-text-stream`} key={m.id}>
473
- <div data-testid={`message-${idx}-id`}>{m.id}</div>
474
- <div data-testid={`message-${idx}-role`}>
475
- {m.role === 'user' ? 'User: ' : 'AI: '}
476
- </div>
477
- <div data-testid={`message-${idx}-content`}>
478
- {m.parts
479
- .map(part => (part.type === 'text' ? part.text : ''))
480
- .join('')}
481
- </div>
482
- </div>
483
- ))}
484
-
485
- <button
486
- data-testid="do-send"
487
- onClick={() => {
488
- sendMessage({
489
- role: 'user',
490
- parts: [{ text: 'hi', type: 'text' }],
491
- });
492
- }}
493
- />
494
- </div>
495
- );
496
- });
497
-
498
- beforeEach(() => {
499
- onFinishCalls = [];
500
- });
501
-
502
- it('should show streamed response', async () => {
503
- server.urls['/api/chat'].response = {
504
- type: 'stream-chunks',
505
- chunks: ['Hello', ',', ' world', '.'],
506
- };
507
-
508
- await userEvent.click(screen.getByTestId('do-send'));
509
-
510
- await screen.findByTestId('message-0-content');
511
- expect(screen.getByTestId('message-0-content')).toHaveTextContent('hi');
512
-
513
- await screen.findByTestId('message-1-content');
514
- expect(screen.getByTestId('message-1-content')).toHaveTextContent(
515
- 'Hello, world.',
516
- );
517
- });
518
-
519
- it('should have stable message ids', async () => {
520
- const controller = new TestResponseController();
521
-
522
- server.urls['/api/chat'].response = {
523
- type: 'controlled-stream',
524
- controller,
525
- };
526
-
527
- await userEvent.click(screen.getByTestId('do-send'));
528
-
529
- controller.write('He');
530
-
531
- await screen.findByTestId('message-1-content');
532
- expect(screen.getByTestId('message-1-content')).toHaveTextContent('He');
533
-
534
- const id = screen.getByTestId('message-1-id').textContent;
535
-
536
- controller.write('llo');
537
- controller.close();
538
-
539
- await screen.findByTestId('message-1-content');
540
- expect(screen.getByTestId('message-1-content')).toHaveTextContent('Hello');
541
- expect(screen.getByTestId('message-1-id').textContent).toBe(id);
542
- });
543
-
544
- it('should invoke onFinish when the stream finishes', async () => {
545
- server.urls['/api/chat'].response = {
546
- type: 'stream-chunks',
547
- chunks: ['Hello', ',', ' world', '.'],
548
- };
549
-
550
- await userEvent.click(screen.getByTestId('do-send'));
551
-
552
- await screen.findByTestId('message-1-text-stream');
553
-
554
- expect(onFinishCalls).toMatchInlineSnapshot(`
555
- [
556
- {
557
- "finishReason": undefined,
558
- "isAbort": false,
559
- "isDisconnect": false,
560
- "isError": false,
561
- "message": {
562
- "id": "id-2",
563
- "metadata": undefined,
564
- "parts": [
565
- {
566
- "type": "step-start",
567
- },
568
- {
569
- "providerMetadata": undefined,
570
- "state": "done",
571
- "text": "Hello, world.",
572
- "type": "text",
573
- },
574
- ],
575
- "role": "assistant",
576
- },
577
- "messages": [
578
- {
579
- "id": "id-1",
580
- "metadata": undefined,
581
- "parts": [
582
- {
583
- "text": "hi",
584
- "type": "text",
585
- },
586
- ],
587
- "role": "user",
588
- },
589
- {
590
- "id": "id-2",
591
- "metadata": undefined,
592
- "parts": [
593
- {
594
- "type": "step-start",
595
- },
596
- {
597
- "providerMetadata": undefined,
598
- "state": "done",
599
- "text": "Hello, world.",
600
- "type": "text",
601
- },
602
- ],
603
- "role": "assistant",
604
- },
605
- ],
606
- },
607
- ]
608
- `);
609
- });
610
- });
611
-
612
- describe('prepareChatRequest', () => {
613
- let options: any;
614
-
615
- setupTestComponent(() => {
616
- const { messages, sendMessage, status } = useChat({
617
- transport: new DefaultChatTransport({
618
- body: { 'body-key': 'body-value' },
619
- headers: { 'header-key': 'header-value' },
620
- prepareSendMessagesRequest(optionsArg) {
621
- options = optionsArg;
622
- return {
623
- body: { 'request-body-key': 'request-body-value' },
624
- headers: { 'header-key': 'header-value' },
625
- };
626
- },
627
- }),
628
- generateId: mockId(),
629
- });
630
-
631
- return (
632
- <div>
633
- <div data-testid="status">{status.toString()}</div>
634
- {messages.map((m, idx) => (
635
- <div data-testid={`message-${idx}`} key={m.id}>
636
- {m.role === 'user' ? 'User: ' : 'AI: '}
637
- {m.parts
638
- .map(part => (part.type === 'text' ? part.text : ''))
639
- .join('')}
640
- </div>
641
- ))}
642
-
643
- <button
644
- data-testid="do-send"
645
- onClick={() => {
646
- sendMessage(
647
- {
648
- parts: [{ text: 'hi', type: 'text' }],
649
- },
650
- {
651
- body: { 'request-body-key': 'request-body-value' },
652
- headers: { 'request-header-key': 'request-header-value' },
653
- metadata: { 'request-metadata-key': 'request-metadata-value' },
654
- },
655
- );
656
- }}
657
- />
658
- </div>
659
- );
660
- });
661
-
662
- afterEach(() => {
663
- options = undefined;
664
- });
665
-
666
- it('should show streamed response', async () => {
667
- server.urls['/api/chat'].response = {
668
- type: 'stream-chunks',
669
- chunks: [
670
- formatChunk({ type: 'text-start', id: '0' }),
671
- formatChunk({ type: 'text-delta', id: '0', delta: 'Hello' }),
672
- formatChunk({ type: 'text-delta', id: '0', delta: ',' }),
673
- formatChunk({ type: 'text-delta', id: '0', delta: ' world' }),
674
- formatChunk({ type: 'text-delta', id: '0', delta: '.' }),
675
- formatChunk({ type: 'text-end', id: '0' }),
676
- ],
677
- };
678
-
679
- await userEvent.click(screen.getByTestId('do-send'));
680
-
681
- await screen.findByTestId('message-0');
682
- expect(screen.getByTestId('message-0')).toHaveTextContent('User: hi');
683
-
684
- expect(options).toMatchInlineSnapshot(`
685
- {
686
- "api": "/api/chat",
687
- "body": {
688
- "body-key": "body-value",
689
- "request-body-key": "request-body-value",
690
- },
691
- "credentials": undefined,
692
- "headers": {
693
- "header-key": "header-value",
694
- "request-header-key": "request-header-value",
695
- },
696
- "id": "id-0",
697
- "messageId": undefined,
698
- "messages": [
699
- {
700
- "id": "id-1",
701
- "metadata": undefined,
702
- "parts": [
703
- {
704
- "text": "hi",
705
- "type": "text",
706
- },
707
- ],
708
- "role": "user",
709
- },
710
- ],
711
- "requestMetadata": {
712
- "request-metadata-key": "request-metadata-value",
713
- },
714
- "trigger": "submit-message",
715
- }
716
- `);
717
-
718
- expect(await server.calls[0].requestBodyJson).toMatchInlineSnapshot(`
719
- {
720
- "request-body-key": "request-body-value",
721
- }
722
- `);
723
- expect(server.calls[0].requestHeaders).toMatchInlineSnapshot(`
724
- {
725
- "content-type": "application/json",
726
- "header-key": "header-value",
727
- }
728
- `);
729
-
730
- await screen.findByTestId('message-1');
731
- expect(screen.getByTestId('message-1')).toHaveTextContent(
732
- 'AI: Hello, world.',
733
- );
734
- });
735
- });
736
-
737
- describe('onToolCall', () => {
738
- let resolve: () => void;
739
- let toolCallPromise: Promise<void>;
740
-
741
- setupTestComponent(() => {
742
- const { messages, sendMessage, addToolOutput } = useChat({
743
- async onToolCall({ toolCall }) {
744
- await toolCallPromise;
745
- addToolOutput({
746
- tool: 'test-tool',
747
- toolCallId: toolCall.toolCallId,
748
- output: `test-tool-response: ${toolCall.toolName} ${
749
- toolCall.toolCallId
750
- } ${JSON.stringify(toolCall.input)}`,
751
- });
752
- },
753
- });
754
-
755
- return (
756
- <div>
757
- {messages.map((m, idx) => (
758
- <div data-testid={`message-${idx}`} key={m.id}>
759
- {m.parts.filter(isStaticToolUIPart).map((toolPart, toolIdx) => (
760
- <div key={toolIdx} data-testid={`tool-${toolIdx}`}>
761
- {JSON.stringify(toolPart)}
762
- </div>
763
- ))}
764
- </div>
765
- ))}
766
-
767
- <button
768
- data-testid="do-send"
769
- onClick={() => {
770
- sendMessage({
771
- parts: [{ text: 'hi', type: 'text' }],
772
- });
773
- }}
774
- />
775
- </div>
776
- );
777
- });
778
-
779
- beforeEach(() => {
780
- toolCallPromise = new Promise(resolveArg => {
781
- resolve = resolveArg;
782
- });
783
- });
784
-
785
- it("should invoke onToolCall when a tool call is received from the server's response", async () => {
786
- server.urls['/api/chat'].response = {
787
- type: 'stream-chunks',
788
- chunks: [
789
- formatChunk({
790
- type: 'tool-input-available',
791
- toolCallId: 'tool-call-0',
792
- toolName: 'test-tool',
793
- input: { testArg: 'test-value' },
794
- }),
795
- ],
796
- };
797
-
798
- await userEvent.click(screen.getByTestId('do-send'));
799
-
800
- await screen.findByTestId('message-1');
801
- expect(
802
- JSON.parse(screen.getByTestId('message-1').textContent ?? ''),
803
- ).toStrictEqual({
804
- state: 'input-available',
805
- input: { testArg: 'test-value' },
806
- toolCallId: 'tool-call-0',
807
- type: 'tool-test-tool',
808
- });
809
-
810
- resolve();
811
-
812
- await waitFor(() => {
813
- expect(
814
- JSON.parse(screen.getByTestId('message-1').textContent ?? ''),
815
- ).toStrictEqual({
816
- state: 'output-available',
817
- input: { testArg: 'test-value' },
818
- toolCallId: 'tool-call-0',
819
- type: 'tool-test-tool',
820
- output:
821
- 'test-tool-response: test-tool tool-call-0 {"testArg":"test-value"}',
822
- });
823
- });
824
- });
825
-
826
- it('should call the latest onToolCall after prop change (no stale closure)', async () => {
827
- const onToolCallA = vi.fn(async () => {});
828
- const onToolCallB = vi.fn(async () => {});
829
-
830
- const Test = () => {
831
- const [useB, setUseB] = React.useState(false);
832
- const { sendMessage } = useChat({
833
- onToolCall: useB ? onToolCallB : onToolCallA,
834
- });
835
-
836
- return (
837
- <div>
838
- <button data-testid="toggle" onClick={() => setUseB(true)} />
839
- <button
840
- data-testid="do-send"
841
- onClick={() => {
842
- sendMessage({
843
- parts: [{ text: 'hi', type: 'text' }],
844
- });
845
- }}
846
- />
847
- </div>
848
- );
849
- };
850
-
851
- render(<Test />);
852
-
853
- server.urls['/api/chat'].response = {
854
- type: 'stream-chunks',
855
- chunks: [
856
- formatChunk({
857
- type: 'tool-input-available',
858
- toolCallId: 'tool-call-0',
859
- toolName: 'test-tool',
860
- input: { testArg: 'test-value' },
861
- }),
862
- ],
863
- };
864
-
865
- await userEvent.click(screen.getByTestId('toggle'));
866
- const sendButtons = screen.getAllByTestId('do-send');
867
- await userEvent.click(sendButtons[sendButtons.length - 1]);
868
-
869
- await vi.waitUntil(() => onToolCallB.mock.calls.length > 0, {
870
- timeout: 2000,
871
- });
872
-
873
- expect(onToolCallA).toHaveBeenCalledTimes(0);
874
- expect(onToolCallB).toHaveBeenCalledTimes(1);
875
- });
876
- });
877
-
878
- describe('tool invocations', () => {
879
- setupTestComponent(() => {
880
- const { messages, sendMessage, addToolOutput } = useChat({
881
- generateId: mockId(),
882
- });
883
-
884
- return (
885
- <div>
886
- {messages.map((m, idx) => (
887
- <div data-testid={`message-${idx}`} key={m.id}>
888
- {m.parts.filter(isStaticToolUIPart).map((toolPart, toolIdx) => {
889
- return (
890
- <div key={toolIdx}>
891
- <div data-testid={`tool-invocation-${toolIdx}`}>
892
- {JSON.stringify(toolPart)}
893
- </div>
894
- {toolPart.state === 'input-available' && (
895
- <button
896
- data-testid={`add-result-${toolIdx}`}
897
- onClick={() => {
898
- addToolOutput({
899
- tool: 'test-tool',
900
- toolCallId: toolPart.toolCallId,
901
- output: 'test-result',
902
- });
903
- }}
904
- />
905
- )}
906
- </div>
907
- );
908
- })}
909
- {m.role === 'assistant' && (
910
- <div data-testid={`message-${idx}-text`}>
911
- {m.parts
912
- .map(part => (part.type === 'text' ? part.text : ''))
913
- .join('')}
914
- </div>
915
- )}
916
- </div>
917
- ))}
918
-
919
- <div data-testid="messages">{JSON.stringify(messages, null, 2)}</div>
920
-
921
- <button
922
- data-testid="do-send"
923
- onClick={() => {
924
- sendMessage({
925
- parts: [{ text: 'hi', type: 'text' }],
926
- });
927
- }}
928
- />
929
- </div>
930
- );
931
- });
932
-
933
- it('should display partial tool call, tool call, and tool result', async () => {
934
- const controller = new TestResponseController();
935
-
936
- server.urls['/api/chat'].response = {
937
- type: 'controlled-stream',
938
- controller,
939
- };
940
-
941
- await userEvent.click(screen.getByTestId('do-send'));
942
-
943
- controller.write(
944
- formatChunk({
945
- type: 'tool-input-start',
946
- toolCallId: 'tool-call-0',
947
- toolName: 'test-tool',
948
- }),
949
- );
950
-
951
- await waitFor(() => {
952
- expect(
953
- JSON.parse(screen.getByTestId('message-1').textContent ?? ''),
954
- ).toStrictEqual({
955
- state: 'input-streaming',
956
- toolCallId: 'tool-call-0',
957
- type: 'tool-test-tool',
958
- });
959
- });
960
-
961
- controller.write(
962
- formatChunk({
963
- type: 'tool-input-delta',
964
- toolCallId: 'tool-call-0',
965
- inputTextDelta: '{"testArg":"t',
966
- }),
967
- );
968
-
969
- await waitFor(() => {
970
- expect(
971
- JSON.parse(screen.getByTestId('message-1').textContent ?? ''),
972
- ).toStrictEqual({
973
- state: 'input-streaming',
974
- toolCallId: 'tool-call-0',
975
- type: 'tool-test-tool',
976
- input: { testArg: 't' },
977
- });
978
- });
979
-
980
- controller.write(
981
- formatChunk({
982
- type: 'tool-input-delta',
983
- toolCallId: 'tool-call-0',
984
- inputTextDelta: 'est-value"}}',
985
- }),
986
- );
987
-
988
- await waitFor(() => {
989
- expect(
990
- JSON.parse(screen.getByTestId('message-1').textContent ?? ''),
991
- ).toStrictEqual({
992
- state: 'input-streaming',
993
- toolCallId: 'tool-call-0',
994
- type: 'tool-test-tool',
995
- input: { testArg: 'test-value' },
996
- });
997
- });
998
-
999
- controller.write(
1000
- formatChunk({
1001
- type: 'tool-input-available',
1002
- toolCallId: 'tool-call-0',
1003
- toolName: 'test-tool',
1004
- input: { testArg: 'test-value' },
1005
- }),
1006
- );
1007
-
1008
- await waitFor(() => {
1009
- expect(
1010
- JSON.parse(screen.getByTestId('message-1').textContent ?? ''),
1011
- ).toStrictEqual({
1012
- state: 'input-available',
1013
- input: { testArg: 'test-value' },
1014
- toolCallId: 'tool-call-0',
1015
- type: 'tool-test-tool',
1016
- });
1017
- });
1018
-
1019
- controller.write(
1020
- formatChunk({
1021
- type: 'tool-output-available',
1022
- toolCallId: 'tool-call-0',
1023
- output: 'test-result',
1024
- }),
1025
- );
1026
- controller.close();
1027
-
1028
- await waitFor(() => {
1029
- expect(
1030
- JSON.parse(screen.getByTestId('message-1').textContent ?? ''),
1031
- ).toStrictEqual({
1032
- state: 'output-available',
1033
- input: { testArg: 'test-value' },
1034
- toolCallId: 'tool-call-0',
1035
- type: 'tool-test-tool',
1036
- output: 'test-result',
1037
- });
1038
- });
1039
- });
1040
-
1041
- it('should display tool call and tool result (when there is no tool call streaming)', async () => {
1042
- const controller = new TestResponseController();
1043
- server.urls['/api/chat'].response = {
1044
- type: 'controlled-stream',
1045
- controller,
1046
- };
1047
-
1048
- await userEvent.click(screen.getByTestId('do-send'));
1049
-
1050
- controller.write(
1051
- formatChunk({
1052
- type: 'tool-input-available',
1053
- toolCallId: 'tool-call-0',
1054
- toolName: 'test-tool',
1055
- input: { testArg: 'test-value' },
1056
- }),
1057
- );
1058
-
1059
- await waitFor(() => {
1060
- expect(
1061
- JSON.parse(screen.getByTestId('message-1').textContent ?? ''),
1062
- ).toStrictEqual({
1063
- state: 'input-available',
1064
- input: { testArg: 'test-value' },
1065
- toolCallId: 'tool-call-0',
1066
- type: 'tool-test-tool',
1067
- });
1068
- });
1069
-
1070
- controller.write(
1071
- formatChunk({
1072
- type: 'tool-output-available',
1073
- toolCallId: 'tool-call-0',
1074
- output: 'test-result',
1075
- }),
1076
- );
1077
- controller.close();
1078
-
1079
- await waitFor(() => {
1080
- expect(
1081
- JSON.parse(screen.getByTestId('message-1').textContent ?? ''),
1082
- ).toStrictEqual({
1083
- state: 'output-available',
1084
- input: { testArg: 'test-value' },
1085
- toolCallId: 'tool-call-0',
1086
- type: 'tool-test-tool',
1087
- output: 'test-result',
1088
- });
1089
- });
1090
- });
1091
-
1092
- it('should update tool call to result when addToolOutput is called', async () => {
1093
- const controller = new TestResponseController();
1094
- server.urls['/api/chat'].response = {
1095
- type: 'controlled-stream',
1096
- controller,
1097
- };
1098
-
1099
- await userEvent.click(screen.getByTestId('do-send'));
1100
-
1101
- controller.write(formatChunk({ type: 'start' }));
1102
- controller.write(formatChunk({ type: 'start-step' }));
1103
- controller.write(
1104
- formatChunk({
1105
- type: 'tool-input-available',
1106
- toolCallId: 'tool-call-0',
1107
- toolName: 'test-tool',
1108
- input: { testArg: 'test-value' },
1109
- }),
1110
- );
1111
-
1112
- await waitFor(() => {
1113
- expect(
1114
- JSON.parse(screen.getByTestId('message-1').textContent ?? ''),
1115
- ).toStrictEqual({
1116
- state: 'input-available',
1117
- input: { testArg: 'test-value' },
1118
- toolCallId: 'tool-call-0',
1119
- type: 'tool-test-tool',
1120
- });
1121
- });
1122
-
1123
- await userEvent.click(screen.getByTestId('add-result-0'));
1124
-
1125
- await waitFor(() => {
1126
- expect(
1127
- JSON.parse(screen.getByTestId('message-1').textContent ?? ''),
1128
- ).toStrictEqual({
1129
- state: 'output-available',
1130
- input: { testArg: 'test-value' },
1131
- toolCallId: 'tool-call-0',
1132
- type: 'tool-test-tool',
1133
- output: 'test-result',
1134
- });
1135
- });
1136
-
1137
- controller.write(formatChunk({ type: 'text-start', id: '0' }));
1138
- controller.write(
1139
- formatChunk({
1140
- type: 'text-delta',
1141
- id: '0',
1142
- delta: 'more text',
1143
- }),
1144
- );
1145
- controller.write(formatChunk({ type: 'text-end', id: '0' }));
1146
- controller.close();
1147
-
1148
- await waitFor(() => {
1149
- expect(
1150
- JSON.parse(screen.getByTestId('messages').textContent ?? ''),
1151
- ).toStrictEqual([
1152
- {
1153
- id: 'id-1',
1154
- parts: [
1155
- {
1156
- text: 'hi',
1157
- type: 'text',
1158
- },
1159
- ],
1160
- role: 'user',
1161
- },
1162
- {
1163
- id: 'id-2',
1164
- parts: [
1165
- {
1166
- type: 'step-start',
1167
- },
1168
- {
1169
- type: 'tool-test-tool',
1170
- toolCallId: 'tool-call-0',
1171
- input: { testArg: 'test-value' },
1172
- output: 'test-result',
1173
- state: 'output-available',
1174
- },
1175
- {
1176
- text: 'more text',
1177
- type: 'text',
1178
- state: 'done',
1179
- },
1180
- ],
1181
- role: 'assistant',
1182
- },
1183
- ]);
1184
- });
1185
- });
1186
- });
1187
-
1188
- describe('file attachments with data url', () => {
1189
- setupTestComponent(() => {
1190
- const { messages, status, sendMessage } = useChat({
1191
- generateId: mockId(),
1192
- });
1193
-
1194
- const [files, setFiles] = useState<FileList | undefined>(undefined);
1195
- const fileInputRef = useRef<HTMLInputElement>(null);
1196
- const [input, setInput] = useState('');
1197
-
1198
- return (
1199
- <div>
1200
- <div data-testid="messages">{JSON.stringify(messages, null, 2)}</div>
1201
-
1202
- <form
1203
- onSubmit={() => {
1204
- sendMessage({ text: input, files });
1205
- setFiles(undefined);
1206
- if (fileInputRef.current) {
1207
- fileInputRef.current.value = '';
1208
- }
1209
- }}
1210
- data-testid="chat-form"
1211
- >
1212
- <input
1213
- type="file"
1214
- onChange={event => {
1215
- if (event.target.files) {
1216
- setFiles(event.target.files);
1217
- }
1218
- }}
1219
- multiple
1220
- ref={fileInputRef}
1221
- data-testid="file-input"
1222
- />
1223
- <input
1224
- value={input}
1225
- onChange={e => setInput(e.target.value)}
1226
- disabled={status !== 'ready'}
1227
- data-testid="message-input"
1228
- />
1229
- <button type="submit" data-testid="submit-button">
1230
- Send
1231
- </button>
1232
- </form>
1233
- </div>
1234
- );
1235
- });
1236
-
1237
- it('should handle text file attachment and submission', async () => {
1238
- server.urls['/api/chat'].response = {
1239
- type: 'stream-chunks',
1240
- chunks: [
1241
- formatChunk({
1242
- type: 'text-start',
1243
- id: '0',
1244
- }),
1245
- formatChunk({
1246
- type: 'text-delta',
1247
- id: '0',
1248
- delta: 'Response to message with text attachment',
1249
- }),
1250
- formatChunk({ type: 'text-end', id: '0' }),
1251
- ],
1252
- };
1253
-
1254
- const file = new File(['test file content'], 'test.txt', {
1255
- type: 'text/plain',
1256
- });
1257
-
1258
- const fileInput = screen.getByTestId('file-input');
1259
- await userEvent.upload(fileInput, file);
1260
-
1261
- const messageInput = screen.getByTestId('message-input');
1262
- await userEvent.type(messageInput, 'Message with text attachment');
1263
-
1264
- const submitButton = screen.getByTestId('submit-button');
1265
- await userEvent.click(submitButton);
1266
-
1267
- await waitFor(() => {
1268
- expect(
1269
- JSON.parse(screen.getByTestId('messages').textContent ?? ''),
1270
- ).toStrictEqual([
1271
- {
1272
- id: 'id-1',
1273
- role: 'user',
1274
- parts: [
1275
- {
1276
- type: 'file',
1277
- mediaType: 'text/plain',
1278
- filename: 'test.txt',
1279
- url: 'data:text/plain;base64,dGVzdCBmaWxlIGNvbnRlbnQ=',
1280
- },
1281
- {
1282
- type: 'text',
1283
- text: 'Message with text attachment',
1284
- },
1285
- ],
1286
- },
1287
- {
1288
- id: 'id-2',
1289
- parts: [
1290
- {
1291
- text: 'Response to message with text attachment',
1292
- type: 'text',
1293
- state: 'done',
1294
- },
1295
- ],
1296
- role: 'assistant',
1297
- },
1298
- ]);
1299
- });
1300
-
1301
- expect(await server.calls[0].requestBodyJson).toMatchInlineSnapshot(`
1302
- {
1303
- "id": "id-0",
1304
- "messages": [
1305
- {
1306
- "id": "id-1",
1307
- "parts": [
1308
- {
1309
- "filename": "test.txt",
1310
- "mediaType": "text/plain",
1311
- "type": "file",
1312
- "url": "data:text/plain;base64,dGVzdCBmaWxlIGNvbnRlbnQ=",
1313
- },
1314
- {
1315
- "text": "Message with text attachment",
1316
- "type": "text",
1317
- },
1318
- ],
1319
- "role": "user",
1320
- },
1321
- ],
1322
- "trigger": "submit-message",
1323
- }
1324
- `);
1325
- });
1326
-
1327
- it('should handle image file attachment and submission', async () => {
1328
- server.urls['/api/chat'].response = {
1329
- type: 'stream-chunks',
1330
- chunks: [
1331
- formatChunk({
1332
- type: 'text-start',
1333
- id: '0',
1334
- }),
1335
- formatChunk({
1336
- type: 'text-delta',
1337
- id: '0',
1338
- delta: 'Response to message with image attachment',
1339
- }),
1340
- formatChunk({ type: 'text-end', id: '0' }),
1341
- ],
1342
- };
1343
-
1344
- const file = new File(['test image content'], 'test.png', {
1345
- type: 'image/png',
1346
- });
1347
-
1348
- const fileInput = screen.getByTestId('file-input');
1349
- await userEvent.upload(fileInput, file);
1350
-
1351
- const messageInput = screen.getByTestId('message-input');
1352
- await userEvent.type(messageInput, 'Message with image attachment');
1353
-
1354
- const submitButton = screen.getByTestId('submit-button');
1355
- await userEvent.click(submitButton);
1356
-
1357
- await waitFor(() => {
1358
- expect(
1359
- JSON.parse(screen.getByTestId('messages').textContent ?? ''),
1360
- ).toStrictEqual([
1361
- {
1362
- role: 'user',
1363
- id: 'id-1',
1364
- parts: [
1365
- {
1366
- type: 'file',
1367
- mediaType: 'image/png',
1368
- filename: 'test.png',
1369
- url: 'data:image/png;base64,dGVzdCBpbWFnZSBjb250ZW50',
1370
- },
1371
- {
1372
- type: 'text',
1373
- text: 'Message with image attachment',
1374
- },
1375
- ],
1376
- },
1377
- {
1378
- role: 'assistant',
1379
- id: 'id-2',
1380
- parts: [
1381
- {
1382
- type: 'text',
1383
- text: 'Response to message with image attachment',
1384
- state: 'done',
1385
- },
1386
- ],
1387
- },
1388
- ]);
1389
- });
1390
-
1391
- expect(await server.calls[0].requestBodyJson).toMatchInlineSnapshot(`
1392
- {
1393
- "id": "id-0",
1394
- "messages": [
1395
- {
1396
- "id": "id-1",
1397
- "parts": [
1398
- {
1399
- "filename": "test.png",
1400
- "mediaType": "image/png",
1401
- "type": "file",
1402
- "url": "data:image/png;base64,dGVzdCBpbWFnZSBjb250ZW50",
1403
- },
1404
- {
1405
- "text": "Message with image attachment",
1406
- "type": "text",
1407
- },
1408
- ],
1409
- "role": "user",
1410
- },
1411
- ],
1412
- "trigger": "submit-message",
1413
- }
1414
- `);
1415
- });
1416
- });
1417
-
1418
- describe('file attachments with url', () => {
1419
- setupTestComponent(() => {
1420
- const { messages, sendMessage, status } = useChat({
1421
- generateId: mockId(),
1422
- });
1423
-
1424
- const [input, setInput] = useState('');
1425
-
1426
- return (
1427
- <div>
1428
- <div data-testid="messages">{JSON.stringify(messages, null, 2)}</div>
1429
-
1430
- <form
1431
- onSubmit={() => {
1432
- sendMessage({
1433
- text: input,
1434
- files: [
1435
- {
1436
- type: 'file',
1437
- mediaType: 'image/png',
1438
- url: 'https://example.com/image.png',
1439
- },
1440
- ],
1441
- });
1442
- }}
1443
- data-testid="chat-form"
1444
- >
1445
- <input
1446
- value={input}
1447
- onChange={e => setInput(e.target.value)}
1448
- disabled={status !== 'ready'}
1449
- data-testid="message-input"
1450
- />
1451
- <button type="submit" data-testid="submit-button">
1452
- Send
1453
- </button>
1454
- </form>
1455
- </div>
1456
- );
1457
- });
1458
-
1459
- it('should handle image file attachment and submission', async () => {
1460
- server.urls['/api/chat'].response = {
1461
- type: 'stream-chunks',
1462
- chunks: [
1463
- formatChunk({
1464
- type: 'text-start',
1465
- id: '0',
1466
- }),
1467
- formatChunk({
1468
- type: 'text-delta',
1469
- id: '0',
1470
- delta: 'Response to message with image attachment',
1471
- }),
1472
- formatChunk({ type: 'text-end', id: '0' }),
1473
- ],
1474
- };
1475
-
1476
- const messageInput = screen.getByTestId('message-input');
1477
- await userEvent.type(messageInput, 'Message with image attachment');
1478
-
1479
- const submitButton = screen.getByTestId('submit-button');
1480
- await userEvent.click(submitButton);
1481
-
1482
- await waitFor(() => {
1483
- expect(
1484
- JSON.parse(screen.getByTestId('messages').textContent ?? ''),
1485
- ).toStrictEqual([
1486
- {
1487
- role: 'user',
1488
- id: 'id-1',
1489
- parts: [
1490
- {
1491
- type: 'file',
1492
- mediaType: 'image/png',
1493
- url: 'https://example.com/image.png',
1494
- },
1495
- {
1496
- type: 'text',
1497
- text: 'Message with image attachment',
1498
- },
1499
- ],
1500
- },
1501
- {
1502
- role: 'assistant',
1503
- id: 'id-2',
1504
- parts: [
1505
- {
1506
- type: 'text',
1507
- text: 'Response to message with image attachment',
1508
- state: 'done',
1509
- },
1510
- ],
1511
- },
1512
- ]);
1513
- });
1514
-
1515
- expect(await server.calls[0].requestBodyJson).toMatchInlineSnapshot(`
1516
- {
1517
- "id": "id-0",
1518
- "messages": [
1519
- {
1520
- "id": "id-1",
1521
- "parts": [
1522
- {
1523
- "mediaType": "image/png",
1524
- "type": "file",
1525
- "url": "https://example.com/image.png",
1526
- },
1527
- {
1528
- "text": "Message with image attachment",
1529
- "type": "text",
1530
- },
1531
- ],
1532
- "role": "user",
1533
- },
1534
- ],
1535
- "trigger": "submit-message",
1536
- }
1537
- `);
1538
- });
1539
- });
1540
-
1541
- describe('attachments with empty submit', () => {
1542
- setupTestComponent(() => {
1543
- const { messages, sendMessage } = useChat({
1544
- generateId: mockId(),
1545
- });
1546
-
1547
- return (
1548
- <div>
1549
- <div data-testid="messages">{JSON.stringify(messages, null, 2)}</div>
1550
-
1551
- <form
1552
- onSubmit={() => {
1553
- sendMessage({
1554
- files: [
1555
- {
1556
- type: 'file',
1557
- filename: 'test.png',
1558
- mediaType: 'image/png',
1559
- url: 'https://example.com/image.png',
1560
- },
1561
- ],
1562
- });
1563
- }}
1564
- data-testid="chat-form"
1565
- >
1566
- <button type="submit" data-testid="submit-button">
1567
- Send
1568
- </button>
1569
- </form>
1570
- </div>
1571
- );
1572
- });
1573
-
1574
- it('should handle image file attachment and submission', async () => {
1575
- server.urls['/api/chat'].response = {
1576
- type: 'stream-chunks',
1577
- chunks: [
1578
- formatChunk({ type: 'text-start', id: '0' }),
1579
- formatChunk({
1580
- type: 'text-delta',
1581
- id: '0',
1582
- delta: 'Response to message with image attachment',
1583
- }),
1584
- formatChunk({ type: 'text-end', id: '0' }),
1585
- ],
1586
- };
1587
-
1588
- const submitButton = screen.getByTestId('submit-button');
1589
- await userEvent.click(submitButton);
1590
-
1591
- await waitFor(() => {
1592
- expect(
1593
- JSON.parse(screen.getByTestId('messages').textContent ?? ''),
1594
- ).toStrictEqual([
1595
- {
1596
- id: 'id-1',
1597
- role: 'user',
1598
- parts: [
1599
- {
1600
- type: 'file',
1601
- mediaType: 'image/png',
1602
- filename: 'test.png',
1603
- url: 'https://example.com/image.png',
1604
- },
1605
- ],
1606
- },
1607
- {
1608
- id: 'id-2',
1609
- role: 'assistant',
1610
- parts: [
1611
- {
1612
- type: 'text',
1613
- text: 'Response to message with image attachment',
1614
- state: 'done',
1615
- },
1616
- ],
1617
- },
1618
- ]);
1619
- });
1620
-
1621
- expect(await server.calls[0].requestBodyJson).toMatchInlineSnapshot(`
1622
- {
1623
- "id": "id-0",
1624
- "messages": [
1625
- {
1626
- "id": "id-1",
1627
- "parts": [
1628
- {
1629
- "filename": "test.png",
1630
- "mediaType": "image/png",
1631
- "type": "file",
1632
- "url": "https://example.com/image.png",
1633
- },
1634
- ],
1635
- "role": "user",
1636
- },
1637
- ],
1638
- "trigger": "submit-message",
1639
- }
1640
- `);
1641
- });
1642
- });
1643
-
1644
- describe('should send message with attachments', () => {
1645
- setupTestComponent(() => {
1646
- const { messages, sendMessage } = useChat({
1647
- generateId: mockId(),
1648
- });
1649
-
1650
- return (
1651
- <div>
1652
- <div data-testid="messages">{JSON.stringify(messages, null, 2)}</div>
1653
-
1654
- <form
1655
- onSubmit={event => {
1656
- event.preventDefault();
1657
-
1658
- sendMessage({
1659
- parts: [
1660
- {
1661
- type: 'file',
1662
- mediaType: 'image/png',
1663
- url: 'https://example.com/image.png',
1664
- },
1665
- {
1666
- type: 'text',
1667
- text: 'Message with image attachment',
1668
- },
1669
- ],
1670
- });
1671
- }}
1672
- data-testid="chat-form"
1673
- >
1674
- <button type="submit" data-testid="submit-button">
1675
- Send
1676
- </button>
1677
- </form>
1678
- </div>
1679
- );
1680
- });
1681
-
1682
- it('should handle image file attachment and submission', async () => {
1683
- server.urls['/api/chat'].response = {
1684
- type: 'stream-chunks',
1685
- chunks: [
1686
- formatChunk({ type: 'text-start', id: '0' }),
1687
- formatChunk({
1688
- type: 'text-delta',
1689
- id: '0',
1690
- delta: 'Response to message with image attachment',
1691
- }),
1692
- formatChunk({ type: 'text-end', id: '0' }),
1693
- ],
1694
- };
1695
-
1696
- const submitButton = screen.getByTestId('submit-button');
1697
- await userEvent.click(submitButton);
1698
-
1699
- await waitFor(() => {
1700
- expect(
1701
- JSON.parse(screen.getByTestId('messages').textContent ?? ''),
1702
- ).toStrictEqual([
1703
- {
1704
- id: 'id-1',
1705
- parts: [
1706
- {
1707
- mediaType: 'image/png',
1708
- type: 'file',
1709
- url: 'https://example.com/image.png',
1710
- },
1711
- {
1712
- text: 'Message with image attachment',
1713
- type: 'text',
1714
- },
1715
- ],
1716
- role: 'user',
1717
- },
1718
- {
1719
- id: 'id-2',
1720
- parts: [
1721
- {
1722
- state: 'done',
1723
- text: 'Response to message with image attachment',
1724
- type: 'text',
1725
- },
1726
- ],
1727
- role: 'assistant',
1728
- },
1729
- ]);
1730
- });
1731
-
1732
- expect(await server.calls[0].requestBodyJson).toMatchInlineSnapshot(`
1733
- {
1734
- "id": "id-0",
1735
- "messages": [
1736
- {
1737
- "id": "id-1",
1738
- "parts": [
1739
- {
1740
- "mediaType": "image/png",
1741
- "type": "file",
1742
- "url": "https://example.com/image.png",
1743
- },
1744
- {
1745
- "text": "Message with image attachment",
1746
- "type": "text",
1747
- },
1748
- ],
1749
- "role": "user",
1750
- },
1751
- ],
1752
- "trigger": "submit-message",
1753
- }
1754
- `);
1755
- });
1756
- });
1757
-
1758
- describe('regenerate', () => {
1759
- setupTestComponent(() => {
1760
- const { messages, sendMessage, regenerate } = useChat({
1761
- generateId: mockId(),
1762
- });
1763
-
1764
- return (
1765
- <div>
1766
- {messages.map((m, idx) => (
1767
- <div data-testid={`message-${idx}`} key={m.id}>
1768
- {m.role === 'user' ? 'User: ' : 'AI: '}
1769
- {m.parts
1770
- .map(part => (part.type === 'text' ? part.text : ''))
1771
- .join('')}
1772
- </div>
1773
- ))}
1774
-
1775
- <button
1776
- data-testid="do-send"
1777
- onClick={() => {
1778
- sendMessage({ parts: [{ text: 'hi', type: 'text' }] });
1779
- }}
1780
- />
1781
-
1782
- <button
1783
- data-testid="do-regenerate"
1784
- onClick={() => {
1785
- regenerate({
1786
- body: { 'request-body-key': 'request-body-value' },
1787
- headers: { 'header-key': 'header-value' },
1788
- });
1789
- }}
1790
- />
1791
- </div>
1792
- );
1793
- });
1794
-
1795
- it('should show streamed response', async () => {
1796
- server.urls['/api/chat'].response = [
1797
- {
1798
- type: 'stream-chunks',
1799
- chunks: [
1800
- formatChunk({ type: 'text-start', id: '0' }),
1801
- formatChunk({
1802
- type: 'text-delta',
1803
- id: '0',
1804
- delta: 'first response',
1805
- }),
1806
- formatChunk({ type: 'text-end', id: '0' }),
1807
- ],
1808
- },
1809
- {
1810
- type: 'stream-chunks',
1811
- chunks: [
1812
- formatChunk({ type: 'text-start', id: '0' }),
1813
- formatChunk({
1814
- type: 'text-delta',
1815
- id: '0',
1816
- delta: 'second response',
1817
- }),
1818
- formatChunk({ type: 'text-end', id: '0' }),
1819
- ],
1820
- },
1821
- ];
1822
-
1823
- await userEvent.click(screen.getByTestId('do-send'));
1824
-
1825
- await screen.findByTestId('message-0');
1826
- expect(screen.getByTestId('message-0')).toHaveTextContent('User: hi');
1827
-
1828
- await screen.findByTestId('message-1');
1829
-
1830
- // setup done, click reload:
1831
- await userEvent.click(screen.getByTestId('do-regenerate'));
1832
-
1833
- expect(await server.calls[1].requestBodyJson).toMatchInlineSnapshot(`
1834
- {
1835
- "id": "id-0",
1836
- "messages": [
1837
- {
1838
- "id": "id-1",
1839
- "parts": [
1840
- {
1841
- "text": "hi",
1842
- "type": "text",
1843
- },
1844
- ],
1845
- "role": "user",
1846
- },
1847
- ],
1848
- "request-body-key": "request-body-value",
1849
- "trigger": "regenerate-message",
1850
- }
1851
- `);
1852
-
1853
- expect(server.calls[1].requestHeaders).toStrictEqual({
1854
- 'content-type': 'application/json',
1855
- 'header-key': 'header-value',
1856
- });
1857
-
1858
- await screen.findByTestId('message-1');
1859
- expect(screen.getByTestId('message-1')).toHaveTextContent(
1860
- 'AI: second response',
1861
- );
1862
- });
1863
- });
1864
-
1865
- describe('test sending additional fields during message submission', () => {
1866
- setupTestComponent(() => {
1867
- type Message = UIMessage<{ test: string }>;
1868
-
1869
- const { messages, sendMessage } = useChat<Message>({
1870
- generateId: mockId(),
1871
- });
1872
-
1873
- return (
1874
- <div>
1875
- {messages.map((m, idx) => (
1876
- <div data-testid={`message-${idx}`} key={m.id}>
1877
- {m.role === 'user' ? 'User: ' : 'AI: '}
1878
- {m.parts
1879
- .map(part => (part.type === 'text' ? part.text : ''))
1880
- .join('')}
1881
- </div>
1882
- ))}
1883
-
1884
- <button
1885
- data-testid="do-send"
1886
- onClick={() => {
1887
- sendMessage({
1888
- role: 'user',
1889
- metadata: { test: 'example' },
1890
- parts: [{ text: 'hi', type: 'text' }],
1891
- });
1892
- }}
1893
- />
1894
- </div>
1895
- );
1896
- });
1897
-
1898
- it('should send metadata with the message', async () => {
1899
- server.urls['/api/chat'].response = {
1900
- type: 'stream-chunks',
1901
- chunks: [
1902
- formatChunk({ type: 'text-start', id: '0' }),
1903
- formatChunk({
1904
- type: 'text-delta',
1905
- id: '0',
1906
- delta: 'first response',
1907
- }),
1908
- formatChunk({ type: 'text-end', id: '0' }),
1909
- ],
1910
- };
1911
-
1912
- await userEvent.click(screen.getByTestId('do-send'));
1913
-
1914
- await screen.findByTestId('message-0');
1915
- expect(screen.getByTestId('message-0')).toHaveTextContent('User: hi');
1916
-
1917
- expect(await server.calls[0].requestBodyJson).toMatchInlineSnapshot(`
1918
- {
1919
- "id": "id-0",
1920
- "messages": [
1921
- {
1922
- "id": "id-1",
1923
- "metadata": {
1924
- "test": "example",
1925
- },
1926
- "parts": [
1927
- {
1928
- "text": "hi",
1929
- "type": "text",
1930
- },
1931
- ],
1932
- "role": "user",
1933
- },
1934
- ],
1935
- "trigger": "submit-message",
1936
- }
1937
- `);
1938
- });
1939
- });
1940
-
1941
- describe('resume ongoing stream and return assistant message', () => {
1942
- const controller = new TestResponseController();
1943
-
1944
- setupTestComponent(
1945
- () => {
1946
- const { messages, status } = useChat({
1947
- id: '123',
1948
- messages: [
1949
- {
1950
- id: 'msg_123',
1951
- role: 'user',
1952
- parts: [{ type: 'text', text: 'hi' }],
1953
- },
1954
- ],
1955
- generateId: mockId(),
1956
- resume: true,
1957
- });
1958
-
1959
- return (
1960
- <div>
1961
- {messages.map((m, idx) => (
1962
- <div data-testid={`message-${idx}`} key={m.id}>
1963
- {m.role === 'user' ? 'User: ' : 'AI: '}
1964
- {m.parts
1965
- .map(part => (part.type === 'text' ? part.text : ''))
1966
- .join('')}
1967
- </div>
1968
- ))}
1969
-
1970
- <div data-testid="status">{status}</div>
1971
- </div>
1972
- );
1973
- },
1974
- {
1975
- init: TestComponent => {
1976
- server.urls['/api/chat/123/stream'].response = {
1977
- type: 'controlled-stream',
1978
- controller,
1979
- };
1980
-
1981
- return <TestComponent />;
1982
- },
1983
- },
1984
- );
1985
-
1986
- it('construct messages from resumed stream', async () => {
1987
- await screen.findByTestId('message-0');
1988
- expect(screen.getByTestId('message-0')).toHaveTextContent('User: hi');
1989
-
1990
- await waitFor(() => {
1991
- expect(screen.getByTestId('status')).toHaveTextContent('submitted');
1992
- });
1993
-
1994
- controller.write(formatChunk({ type: 'text-start', id: '0' }));
1995
- controller.write(
1996
- formatChunk({ type: 'text-delta', id: '0', delta: 'Hello' }),
1997
- );
1998
-
1999
- await waitFor(() => {
2000
- expect(screen.getByTestId('status')).toHaveTextContent('streaming');
2001
- });
2002
-
2003
- controller.write(formatChunk({ type: 'text-delta', id: '0', delta: ',' }));
2004
- controller.write(
2005
- formatChunk({ type: 'text-delta', id: '0', delta: ' world' }),
2006
- );
2007
- controller.write(formatChunk({ type: 'text-delta', id: '0', delta: '.' }));
2008
- controller.write(formatChunk({ type: 'text-end', id: '0' }));
2009
-
2010
- controller.close();
2011
-
2012
- await screen.findByTestId('message-1');
2013
- expect(screen.getByTestId('message-1')).toHaveTextContent(
2014
- 'AI: Hello, world.',
2015
- );
2016
-
2017
- await waitFor(() => {
2018
- expect(screen.getByTestId('status')).toHaveTextContent('ready');
2019
-
2020
- expect(server.calls.length).toBeGreaterThan(0);
2021
- const mostRecentCall = server.calls[0];
2022
-
2023
- const { requestMethod, requestUrl } = mostRecentCall;
2024
- expect(requestMethod).toBe('GET');
2025
- expect(requestUrl).toBe('http://localhost:3000/api/chat/123/stream');
2026
- });
2027
- });
2028
- });
2029
-
2030
- describe('stop', () => {
2031
- setupTestComponent(() => {
2032
- const { messages, sendMessage, stop, status } = useChat({
2033
- generateId: mockId(),
2034
- });
2035
-
2036
- return (
2037
- <div>
2038
- {messages.map((m, idx) => (
2039
- <div data-testid={`message-${idx}`} key={m.id}>
2040
- {m.role === 'user' ? 'User: ' : 'AI: '}
2041
- {m.parts
2042
- .map(part => (part.type === 'text' ? part.text : ''))
2043
- .join('')}
2044
- </div>
2045
- ))}
2046
-
2047
- <button
2048
- data-testid="do-send"
2049
- onClick={() => {
2050
- sendMessage({
2051
- role: 'user',
2052
- parts: [{ text: 'hi', type: 'text' }],
2053
- });
2054
- }}
2055
- />
2056
-
2057
- <button data-testid="do-stop" onClick={stop} />
2058
-
2059
- <p data-testid="status">{status}</p>
2060
- </div>
2061
- );
2062
- });
2063
-
2064
- it('should show stop response', async () => {
2065
- const controller = new TestResponseController();
2066
-
2067
- server.urls['/api/chat'].response = {
2068
- type: 'controlled-stream',
2069
- controller,
2070
- };
2071
-
2072
- await userEvent.click(screen.getByTestId('do-send'));
2073
-
2074
- controller.write(formatChunk({ type: 'text-start', id: '0' }));
2075
- controller.write(
2076
- formatChunk({ type: 'text-delta', id: '0', delta: 'Hello' }),
2077
- );
2078
-
2079
- await waitFor(() => {
2080
- expect(screen.getByTestId('message-1')).toHaveTextContent('AI: Hello');
2081
- expect(screen.getByTestId('status')).toHaveTextContent('streaming');
2082
- });
2083
-
2084
- await userEvent.click(screen.getByTestId('do-stop'));
2085
-
2086
- await waitFor(() => {
2087
- expect(screen.getByTestId('status')).toHaveTextContent('ready');
2088
- });
2089
-
2090
- await expect(
2091
- controller.write(
2092
- formatChunk({ type: 'text-delta', id: '0', delta: ', world!' }),
2093
- ),
2094
- ).rejects.toThrow();
2095
-
2096
- await expect(controller.close()).rejects.toThrow();
2097
-
2098
- expect(screen.getByTestId('message-1')).toHaveTextContent('AI: Hello');
2099
- expect(screen.getByTestId('status')).toHaveTextContent('ready');
2100
- });
2101
- });
2102
-
2103
- describe('experimental_throttle', () => {
2104
- const throttleMs = 50;
2105
-
2106
- setupTestComponent(() => {
2107
- const { messages, sendMessage, status } = useChat({
2108
- experimental_throttle: throttleMs,
2109
- generateId: mockId(),
2110
- });
2111
-
2112
- return (
2113
- <div>
2114
- <div data-testid="status">{status.toString()}</div>
2115
- {messages.map((m, idx) => (
2116
- <div data-testid={`message-${idx}`} key={m.id}>
2117
- {m.role === 'user' ? 'User: ' : 'AI: '}
2118
- {m.parts
2119
- .map(part => (part.type === 'text' ? part.text : ''))
2120
- .join('')}
2121
- </div>
2122
- ))}
2123
- <button
2124
- data-testid="do-send"
2125
- onClick={() => {
2126
- sendMessage({ parts: [{ text: 'hi', type: 'text' }] });
2127
- }}
2128
- />
2129
- </div>
2130
- );
2131
- });
2132
-
2133
- it('should throttle UI updates when experimental_throttle is set', async () => {
2134
- const controller = new TestResponseController();
2135
-
2136
- server.urls['/api/chat'].response = {
2137
- type: 'controlled-stream',
2138
- controller,
2139
- };
2140
-
2141
- await userEvent.click(screen.getByTestId('do-send'));
2142
- expect(screen.getByTestId('message-0')).toHaveTextContent('User: hi');
2143
-
2144
- vi.useFakeTimers();
2145
-
2146
- controller.write(formatChunk({ type: 'text-start', id: '0' }));
2147
- controller.write(
2148
- formatChunk({ type: 'text-delta', id: '0', delta: 'Hel' }),
2149
- );
2150
- await act(async () => {
2151
- await vi.advanceTimersByTimeAsync(throttleMs + 10);
2152
- });
2153
-
2154
- expect(screen.getByTestId('message-1')).toHaveTextContent('AI: Hel');
2155
-
2156
- controller.write(formatChunk({ type: 'text-delta', id: '0', delta: 'lo' }));
2157
- controller.write(
2158
- formatChunk({ type: 'text-delta', id: '0', delta: ' Th' }),
2159
- );
2160
- controller.write(
2161
- formatChunk({ type: 'text-delta', id: '0', delta: 'ere' }),
2162
- );
2163
- controller.write(formatChunk({ type: 'text-end', id: '0' }));
2164
-
2165
- expect(screen.getByTestId('message-1')).not.toHaveTextContent(
2166
- 'AI: Hello There',
2167
- );
2168
-
2169
- await act(async () => {
2170
- await vi.advanceTimersByTimeAsync(throttleMs + 10);
2171
- });
2172
-
2173
- expect(screen.getByTestId('message-1')).toHaveTextContent(
2174
- 'AI: Hello There',
2175
- );
2176
-
2177
- vi.useRealTimers();
2178
- });
2179
- });
2180
-
2181
- describe('id changes', () => {
2182
- setupTestComponent(
2183
- () => {
2184
- const [id, setId] = React.useState<string>('initial-id');
2185
-
2186
- const {
2187
- messages,
2188
- sendMessage,
2189
- error,
2190
- status,
2191
- id: idKey,
2192
- } = useChat({
2193
- id,
2194
- generateId: mockId(),
2195
- });
2196
-
2197
- return (
2198
- <div>
2199
- <div data-testid="id">{idKey}</div>
2200
- <div data-testid="status">{status.toString()}</div>
2201
- {error && <div data-testid="error">{error.toString()}</div>}
2202
- <div data-testid="messages">{JSON.stringify(messages, null, 2)}</div>
2203
- <button
2204
- data-testid="do-send"
2205
- onClick={() => {
2206
- sendMessage({ parts: [{ text: 'hi', type: 'text' }] });
2207
- }}
2208
- />
2209
- <button
2210
- data-testid="do-change-id"
2211
- onClick={() => {
2212
- setId('second-id');
2213
- }}
2214
- />
2215
- </div>
2216
- );
2217
- },
2218
- {
2219
- init: TestComponent => <TestComponent />,
2220
- },
2221
- );
2222
-
2223
- it('should update chat instance when the id changes', async () => {
2224
- server.urls['/api/chat'].response = {
2225
- type: 'stream-chunks',
2226
- chunks: [
2227
- formatChunk({ type: 'text-start', id: '0' }),
2228
- formatChunk({ type: 'text-delta', id: '0', delta: 'Hello' }),
2229
- formatChunk({ type: 'text-delta', id: '0', delta: ',' }),
2230
- formatChunk({ type: 'text-delta', id: '0', delta: ' world' }),
2231
- formatChunk({ type: 'text-delta', id: '0', delta: '.' }),
2232
- formatChunk({ type: 'text-end', id: '0' }),
2233
- ],
2234
- };
2235
-
2236
- await userEvent.click(screen.getByTestId('do-send'));
2237
-
2238
- await waitFor(() => {
2239
- expect(
2240
- JSON.parse(screen.getByTestId('messages').textContent ?? ''),
2241
- ).toStrictEqual([
2242
- {
2243
- id: expect.any(String),
2244
- parts: [
2245
- {
2246
- text: 'hi',
2247
- type: 'text',
2248
- },
2249
- ],
2250
- role: 'user',
2251
- },
2252
- {
2253
- id: 'id-1',
2254
- parts: [
2255
- {
2256
- text: 'Hello, world.',
2257
- type: 'text',
2258
- state: 'done',
2259
- },
2260
- ],
2261
- role: 'assistant',
2262
- },
2263
- ]);
2264
- });
2265
- await userEvent.click(screen.getByTestId('do-change-id'));
2266
-
2267
- expect(screen.queryByTestId('message-0')).not.toBeInTheDocument();
2268
- });
2269
- });
2270
-
2271
- describe('chat instance changes', () => {
2272
- setupTestComponent(
2273
- () => {
2274
- const [chat, setChat] = React.useState<Chat<UIMessage>>(
2275
- new Chat({
2276
- id: 'initial-id',
2277
- generateId: mockId(),
2278
- }),
2279
- );
2280
-
2281
- const {
2282
- messages,
2283
- sendMessage,
2284
- error,
2285
- status,
2286
- id: idKey,
2287
- } = useChat({
2288
- chat,
2289
- });
2290
-
2291
- return (
2292
- <div>
2293
- <div data-testid="id">{idKey}</div>
2294
- <div data-testid="status">{status.toString()}</div>
2295
- {error && <div data-testid="error">{error.toString()}</div>}
2296
- <div data-testid="messages">{JSON.stringify(messages, null, 2)}</div>
2297
- <button
2298
- data-testid="do-send"
2299
- onClick={() => {
2300
- sendMessage({ parts: [{ text: 'hi', type: 'text' }] });
2301
- }}
2302
- />
2303
- <button
2304
- data-testid="do-change-chat"
2305
- onClick={() => {
2306
- setChat(
2307
- new Chat({
2308
- id: 'second-id',
2309
- generateId: mockId(),
2310
- }),
2311
- );
2312
- }}
2313
- />
2314
- </div>
2315
- );
2316
- },
2317
- {
2318
- init: TestComponent => <TestComponent />,
2319
- },
2320
- );
2321
-
2322
- it('should update chat instance when the id changes', async () => {
2323
- server.urls['/api/chat'].response = {
2324
- type: 'stream-chunks',
2325
- chunks: [
2326
- formatChunk({ type: 'text-start', id: '0' }),
2327
- formatChunk({ type: 'text-delta', id: '0', delta: 'Hello' }),
2328
- formatChunk({ type: 'text-delta', id: '0', delta: ',' }),
2329
- formatChunk({ type: 'text-delta', id: '0', delta: ' world' }),
2330
- formatChunk({ type: 'text-delta', id: '0', delta: '.' }),
2331
- formatChunk({ type: 'text-end', id: '0' }),
2332
- ],
2333
- };
2334
-
2335
- await userEvent.click(screen.getByTestId('do-send'));
2336
-
2337
- await waitFor(() => {
2338
- expect(
2339
- JSON.parse(screen.getByTestId('messages').textContent ?? ''),
2340
- ).toStrictEqual([
2341
- {
2342
- id: expect.any(String),
2343
- parts: [
2344
- {
2345
- text: 'hi',
2346
- type: 'text',
2347
- },
2348
- ],
2349
- role: 'user',
2350
- },
2351
- {
2352
- id: 'id-1',
2353
- parts: [
2354
- {
2355
- text: 'Hello, world.',
2356
- type: 'text',
2357
- state: 'done',
2358
- },
2359
- ],
2360
- role: 'assistant',
2361
- },
2362
- ]);
2363
- });
2364
- await userEvent.click(screen.getByTestId('do-change-chat'));
2365
-
2366
- expect(screen.queryByTestId('message-0')).not.toBeInTheDocument();
2367
- });
2368
-
2369
- it('should handle streaming correctly when the id changes', async () => {
2370
- const controller = new TestResponseController();
2371
- server.urls['/api/chat'].response = {
2372
- type: 'controlled-stream',
2373
- controller,
2374
- };
2375
-
2376
- // First, change the ID
2377
- await userEvent.click(screen.getByTestId('do-change-chat'));
2378
-
2379
- // Then send a message
2380
- await userEvent.click(screen.getByTestId('do-send'));
2381
-
2382
- await waitFor(() => {
2383
- expect(screen.getByTestId('status')).toHaveTextContent('submitted');
2384
- });
2385
-
2386
- controller.write(formatChunk({ type: 'text-start', id: '0' }));
2387
- controller.write(
2388
- formatChunk({ type: 'text-delta', id: '0', delta: 'Hello' }),
2389
- );
2390
-
2391
- // Verify streaming is working - text should appear immediately
2392
- await waitFor(() => {
2393
- expect(
2394
- JSON.parse(screen.getByTestId('messages').textContent ?? ''),
2395
- ).toContainEqual(
2396
- expect.objectContaining({
2397
- role: 'assistant',
2398
- parts: expect.arrayContaining([
2399
- expect.objectContaining({
2400
- type: 'text',
2401
- text: 'Hello',
2402
- }),
2403
- ]),
2404
- }),
2405
- );
2406
- });
2407
-
2408
- controller.write(formatChunk({ type: 'text-delta', id: '0', delta: ',' }));
2409
- controller.write(
2410
- formatChunk({ type: 'text-delta', id: '0', delta: ' world' }),
2411
- );
2412
- controller.write(formatChunk({ type: 'text-delta', id: '0', delta: '.' }));
2413
- controller.write(formatChunk({ type: 'text-end', id: '0' }));
2414
- controller.close();
2415
-
2416
- await waitFor(() => {
2417
- expect(
2418
- JSON.parse(screen.getByTestId('messages').textContent ?? ''),
2419
- ).toContainEqual(
2420
- expect.objectContaining({
2421
- role: 'assistant',
2422
- parts: expect.arrayContaining([
2423
- expect.objectContaining({
2424
- type: 'text',
2425
- text: 'Hello, world.',
2426
- state: 'done',
2427
- }),
2428
- ]),
2429
- }),
2430
- );
2431
- });
2432
- });
2433
- });
2434
-
2435
- describe('streaming with id change from undefined to defined', () => {
2436
- setupTestComponent(
2437
- () => {
2438
- const [id, setId] = React.useState<string | undefined>(undefined);
2439
- const { messages, sendMessage, status } = useChat({
2440
- id,
2441
- generateId: mockId(),
2442
- });
2443
-
2444
- return (
2445
- <div>
2446
- <div data-testid="status">{status.toString()}</div>
2447
- <div data-testid="messages">{JSON.stringify(messages, null, 2)}</div>
2448
- <button
2449
- data-testid="change-id"
2450
- onClick={() => {
2451
- setId('chat-123');
2452
- }}
2453
- />
2454
- <button
2455
- data-testid="send-message"
2456
- onClick={() => {
2457
- sendMessage({ parts: [{ text: 'hi', type: 'text' }] });
2458
- }}
2459
- />
2460
- </div>
2461
- );
2462
- },
2463
- {
2464
- init: TestComponent => <TestComponent />,
2465
- },
2466
- );
2467
-
2468
- it('should handle streaming correctly when id changes from undefined to defined', async () => {
2469
- const controller = new TestResponseController();
2470
- server.urls['/api/chat'].response = {
2471
- type: 'controlled-stream',
2472
- controller,
2473
- };
2474
-
2475
- // First, change the ID from undefined to 'chat-123'
2476
- await userEvent.click(screen.getByTestId('change-id'));
2477
-
2478
- // Then send a message
2479
- await userEvent.click(screen.getByTestId('send-message'));
2480
-
2481
- await waitFor(() => {
2482
- expect(screen.getByTestId('status')).toHaveTextContent('submitted');
2483
- });
2484
-
2485
- controller.write(formatChunk({ type: 'text-start', id: '0' }));
2486
- controller.write(
2487
- formatChunk({ type: 'text-delta', id: '0', delta: 'Hello' }),
2488
- );
2489
-
2490
- // Verify streaming is working - text should appear immediately
2491
- await waitFor(() => {
2492
- expect(
2493
- JSON.parse(screen.getByTestId('messages').textContent ?? ''),
2494
- ).toContainEqual(
2495
- expect.objectContaining({
2496
- role: 'assistant',
2497
- parts: expect.arrayContaining([
2498
- expect.objectContaining({
2499
- type: 'text',
2500
- text: 'Hello',
2501
- }),
2502
- ]),
2503
- }),
2504
- );
2505
- });
2506
-
2507
- controller.write(formatChunk({ type: 'text-delta', id: '0', delta: ',' }));
2508
- controller.write(
2509
- formatChunk({ type: 'text-delta', id: '0', delta: ' world' }),
2510
- );
2511
- controller.write(formatChunk({ type: 'text-delta', id: '0', delta: '.' }));
2512
- controller.write(formatChunk({ type: 'text-end', id: '0' }));
2513
- controller.close();
2514
-
2515
- await waitFor(() => {
2516
- expect(
2517
- JSON.parse(screen.getByTestId('messages').textContent ?? ''),
2518
- ).toContainEqual(
2519
- expect.objectContaining({
2520
- role: 'assistant',
2521
- parts: expect.arrayContaining([
2522
- expect.objectContaining({
2523
- type: 'text',
2524
- text: 'Hello, world.',
2525
- state: 'done',
2526
- }),
2527
- ]),
2528
- }),
2529
- );
2530
- });
2531
- });
2532
- });