@ai-sdk/react 0.0.21 → 0.0.23

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,14 +1,18 @@
1
- import {
2
- mockFetchDataStream,
3
- mockFetchDataStreamWithGenerator,
4
- mockFetchError,
5
- } from '@ai-sdk/ui-utils/test';
1
+ /* eslint-disable @next/next/no-img-element */
2
+ import { withTestServer } from '@ai-sdk/provider-utils/test';
3
+ import { formatStreamPart, getTextFromDataUrl } from '@ai-sdk/ui-utils';
6
4
  import '@testing-library/jest-dom/vitest';
7
- import { cleanup, findByText, render, screen } from '@testing-library/react';
5
+ import {
6
+ RenderResult,
7
+ cleanup,
8
+ findByText,
9
+ render,
10
+ screen,
11
+ waitFor,
12
+ } from '@testing-library/react';
8
13
  import userEvent from '@testing-library/user-event';
9
- import React from 'react';
14
+ import React, { useRef, useState } from 'react';
10
15
  import { useChat } from './use-chat';
11
- import { formatStreamPart } from '@ai-sdk/ui-utils';
12
16
 
13
17
  describe('stream data stream', () => {
14
18
  const TestComponent = () => {
@@ -52,110 +56,127 @@ describe('stream data stream', () => {
52
56
  cleanup();
53
57
  });
54
58
 
55
- it('should show streamed response', async () => {
56
- mockFetchDataStream({
57
- url: 'https://example.com/api/chat',
58
- chunks: ['0:"Hello"\n', '0:","\n', '0:" world"\n', '0:"."\n'],
59
- });
60
-
61
- await userEvent.click(screen.getByTestId('do-append'));
62
-
63
- await screen.findByTestId('message-0');
64
- expect(screen.getByTestId('message-0')).toHaveTextContent('User: hi');
65
-
66
- await screen.findByTestId('message-1');
67
- expect(screen.getByTestId('message-1')).toHaveTextContent(
68
- 'AI: Hello, world.',
69
- );
70
- });
71
-
72
- it('should show streamed response with data', async () => {
73
- mockFetchDataStream({
74
- url: 'https://example.com/api/chat',
75
- chunks: ['2:[{"t1":"v1"}]\n', '0:"Hello"\n'],
76
- });
77
-
78
- await userEvent.click(screen.getByTestId('do-append'));
79
-
80
- await screen.findByTestId('data');
81
- expect(screen.getByTestId('data')).toHaveTextContent('[{"t1":"v1"}]');
59
+ it(
60
+ 'should show streamed response',
61
+ withTestServer(
62
+ {
63
+ type: 'stream-values',
64
+ url: '/api/chat',
65
+ content: ['0:"Hello"\n', '0:","\n', '0:" world"\n', '0:"."\n'],
66
+ },
67
+ async () => {
68
+ await userEvent.click(screen.getByTestId('do-append'));
82
69
 
83
- await screen.findByTestId('message-1');
84
- expect(screen.getByTestId('message-1')).toHaveTextContent('AI: Hello');
85
- });
70
+ await screen.findByTestId('message-0');
71
+ expect(screen.getByTestId('message-0')).toHaveTextContent('User: hi');
86
72
 
87
- it('should show error response', async () => {
88
- mockFetchError({ statusCode: 404, errorMessage: 'Not found' });
73
+ await screen.findByTestId('message-1');
74
+ expect(screen.getByTestId('message-1')).toHaveTextContent(
75
+ 'AI: Hello, world.',
76
+ );
77
+ },
78
+ ),
79
+ );
80
+
81
+ it(
82
+ 'should show streamed response with data',
83
+ withTestServer(
84
+ {
85
+ type: 'stream-values',
86
+ url: '/api/chat',
87
+ content: ['2:[{"t1":"v1"}]\n', '0:"Hello"\n'],
88
+ },
89
+ async () => {
90
+ await userEvent.click(screen.getByTestId('do-append'));
89
91
 
90
- await userEvent.click(screen.getByTestId('do-append'));
92
+ await screen.findByTestId('data');
93
+ expect(screen.getByTestId('data')).toHaveTextContent('[{"t1":"v1"}]');
91
94
 
92
- await screen.findByTestId('error');
93
- expect(screen.getByTestId('error')).toHaveTextContent('Error: Not found');
94
- });
95
+ await screen.findByTestId('message-1');
96
+ expect(screen.getByTestId('message-1')).toHaveTextContent('AI: Hello');
97
+ },
98
+ ),
99
+ );
100
+
101
+ it(
102
+ 'should show error response',
103
+ withTestServer(
104
+ { type: 'error', url: '/api/chat', status: 404, content: 'Not found' },
105
+ async () => {
106
+ await userEvent.click(screen.getByTestId('do-append'));
107
+
108
+ await screen.findByTestId('error');
109
+ expect(screen.getByTestId('error')).toHaveTextContent(
110
+ 'Error: Not found',
111
+ );
112
+ },
113
+ ),
114
+ );
95
115
 
96
116
  describe('loading state', () => {
97
- it('should show loading state', async () => {
98
- let finishGeneration: ((value?: unknown) => void) | undefined;
99
- const finishGenerationPromise = new Promise(resolve => {
100
- finishGeneration = resolve;
101
- });
117
+ it(
118
+ 'should show loading state',
119
+ withTestServer(
120
+ { url: '/api/chat', type: 'controlled-stream' },
121
+ async ({ streamController }) => {
122
+ streamController.enqueue('0:"Hello"\n');
102
123
 
103
- mockFetchDataStreamWithGenerator({
104
- url: 'https://example.com/api/chat',
105
- chunkGenerator: (async function* generate() {
106
- const encoder = new TextEncoder();
107
- yield encoder.encode('0:"Hello"\n');
108
- await finishGenerationPromise;
109
- })(),
110
- });
111
-
112
- await userEvent.click(screen.getByTestId('do-append'));
124
+ await userEvent.click(screen.getByTestId('do-append'));
113
125
 
114
- await screen.findByTestId('loading');
115
- expect(screen.getByTestId('loading')).toHaveTextContent('true');
126
+ await screen.findByTestId('loading');
127
+ expect(screen.getByTestId('loading')).toHaveTextContent('true');
116
128
 
117
- finishGeneration?.();
118
-
119
- await findByText(await screen.findByTestId('loading'), 'false');
120
- expect(screen.getByTestId('loading')).toHaveTextContent('false');
121
- });
129
+ streamController.close();
122
130
 
123
- it('should reset loading state on error', async () => {
124
- mockFetchError({ statusCode: 404, errorMessage: 'Not found' });
131
+ await findByText(await screen.findByTestId('loading'), 'false');
132
+ expect(screen.getByTestId('loading')).toHaveTextContent('false');
133
+ },
134
+ ),
135
+ );
125
136
 
126
- await userEvent.click(screen.getByTestId('do-append'));
137
+ it(
138
+ 'should reset loading state on error',
139
+ withTestServer(
140
+ { type: 'error', url: '/api/chat', status: 404, content: 'Not found' },
141
+ async () => {
142
+ await userEvent.click(screen.getByTestId('do-append'));
127
143
 
128
- await screen.findByTestId('loading');
129
- expect(screen.getByTestId('loading')).toHaveTextContent('false');
130
- });
144
+ await screen.findByTestId('loading');
145
+ expect(screen.getByTestId('loading')).toHaveTextContent('false');
146
+ },
147
+ ),
148
+ );
131
149
  });
132
150
 
133
151
  describe('id', () => {
134
- it('should clear out messages when the id changes', async () => {
135
- mockFetchDataStream({
136
- url: 'https://example.com/api/chat',
137
- chunks: ['0:"Hello"\n', '0:","\n', '0:" world"\n', '0:"."\n'],
138
- });
152
+ it(
153
+ 'should clear out messages when the id changes',
154
+ withTestServer(
155
+ {
156
+ url: '/api/chat',
157
+ type: 'stream-values',
158
+ content: ['0:"Hello"\n', '0:","\n', '0:" world"\n', '0:"."\n'],
159
+ },
160
+ async () => {
161
+ await userEvent.click(screen.getByTestId('do-append'));
139
162
 
140
- await userEvent.click(screen.getByTestId('do-append'));
163
+ await screen.findByTestId('message-1');
164
+ expect(screen.getByTestId('message-1')).toHaveTextContent(
165
+ 'AI: Hello, world.',
166
+ );
141
167
 
142
- await screen.findByTestId('message-1');
143
- expect(screen.getByTestId('message-1')).toHaveTextContent(
144
- 'AI: Hello, world.',
145
- );
168
+ await userEvent.click(screen.getByTestId('do-change-id'));
146
169
 
147
- await userEvent.click(screen.getByTestId('do-change-id'));
148
-
149
- expect(screen.queryByTestId('message-0')).not.toBeInTheDocument();
150
- });
170
+ expect(screen.queryByTestId('message-0')).not.toBeInTheDocument();
171
+ },
172
+ ),
173
+ );
151
174
  });
152
175
  });
153
176
 
154
177
  describe('text stream', () => {
155
178
  const TestComponent = () => {
156
- const { messages, append } = useChat({
157
- streamMode: 'text',
158
- });
179
+ const { messages, append } = useChat({ streamMode: 'text' });
159
180
 
160
181
  return (
161
182
  <div>
@@ -185,38 +206,35 @@ describe('text stream', () => {
185
206
  cleanup();
186
207
  });
187
208
 
188
- it('should show streamed response', async () => {
189
- mockFetchDataStream({
190
- url: 'https://example.com/api/chat',
191
- chunks: ['Hello', ',', ' world', '.'],
192
- });
193
-
194
- await userEvent.click(screen.getByTestId('do-append-text-stream'));
195
-
196
- await screen.findByTestId('message-0-text-stream');
197
- expect(screen.getByTestId('message-0-text-stream')).toHaveTextContent(
198
- 'User: hi',
199
- );
200
-
201
- await screen.findByTestId('message-1-text-stream');
202
- expect(screen.getByTestId('message-1-text-stream')).toHaveTextContent(
203
- 'AI: Hello, world.',
204
- );
205
- });
209
+ it(
210
+ 'should show streamed response',
211
+ withTestServer(
212
+ {
213
+ url: '/api/chat',
214
+ type: 'stream-values',
215
+ content: ['Hello', ',', ' world', '.'],
216
+ },
217
+ async () => {
218
+ await userEvent.click(screen.getByTestId('do-append-text-stream'));
219
+
220
+ await screen.findByTestId('message-0-text-stream');
221
+ expect(screen.getByTestId('message-0-text-stream')).toHaveTextContent(
222
+ 'User: hi',
223
+ );
224
+
225
+ await screen.findByTestId('message-1-text-stream');
226
+ expect(screen.getByTestId('message-1-text-stream')).toHaveTextContent(
227
+ 'AI: Hello, world.',
228
+ );
229
+ },
230
+ ),
231
+ );
206
232
  });
207
233
 
208
234
  describe('form actions', () => {
209
235
  const TestComponent = () => {
210
- const {
211
- messages,
212
- append,
213
- handleSubmit,
214
- handleInputChange,
215
- isLoading,
216
- input,
217
- } = useChat({
218
- streamMode: 'text',
219
- });
236
+ const { messages, handleSubmit, handleInputChange, isLoading, input } =
237
+ useChat({ streamMode: 'text' });
220
238
 
221
239
  return (
222
240
  <div>
@@ -227,12 +245,11 @@ describe('form actions', () => {
227
245
  </div>
228
246
  ))}
229
247
 
230
- <form onSubmit={handleSubmit} className="fixed bottom-0 w-full p-2">
248
+ <form onSubmit={handleSubmit}>
231
249
  <input
232
250
  value={input}
233
251
  placeholder="Send message..."
234
252
  onChange={handleInputChange}
235
- className="w-full p-2 bg-zinc-100"
236
253
  disabled={isLoading}
237
254
  data-testid="do-input"
238
255
  />
@@ -250,37 +267,44 @@ describe('form actions', () => {
250
267
  cleanup();
251
268
  });
252
269
 
253
- it('should show streamed response using handleSubmit', async () => {
254
- mockFetchDataStream({
255
- url: 'https://example.com/api/chat',
256
- chunks: ['Hello', ',', ' world', '.'],
257
- });
258
-
259
- const firstInput = screen.getByTestId('do-input');
260
- await userEvent.type(firstInput, 'hi');
261
- await userEvent.keyboard('{Enter}');
262
-
263
- await screen.findByTestId('message-0');
264
- expect(screen.getByTestId('message-0')).toHaveTextContent('User: hi');
265
-
266
- await screen.findByTestId('message-1');
267
- expect(screen.getByTestId('message-1')).toHaveTextContent(
268
- 'AI: Hello, world.',
269
- );
270
-
271
- mockFetchDataStream({
272
- url: 'https://example.com/api/chat',
273
- chunks: ['How', ' can', ' I', ' help', ' you', '?'],
274
- });
275
-
276
- const secondInput = screen.getByTestId('do-input');
277
- await userEvent.type(secondInput, '{Enter}');
278
-
279
- await screen.findByTestId('message-2');
280
- expect(screen.getByTestId('message-2')).toHaveTextContent(
281
- 'AI: How can I help you?',
282
- );
283
- });
270
+ it(
271
+ 'should show streamed response using handleSubmit',
272
+ withTestServer(
273
+ [
274
+ {
275
+ url: '/api/chat',
276
+ type: 'stream-values',
277
+ content: ['Hello', ',', ' world', '.'],
278
+ },
279
+ {
280
+ url: '/api/chat',
281
+ type: 'stream-values',
282
+ content: ['How', ' can', ' I', ' help', ' you', '?'],
283
+ },
284
+ ],
285
+ async () => {
286
+ const firstInput = screen.getByTestId('do-input');
287
+ await userEvent.type(firstInput, 'hi');
288
+ await userEvent.keyboard('{Enter}');
289
+
290
+ await screen.findByTestId('message-0');
291
+ expect(screen.getByTestId('message-0')).toHaveTextContent('User: hi');
292
+
293
+ await screen.findByTestId('message-1');
294
+ expect(screen.getByTestId('message-1')).toHaveTextContent(
295
+ 'AI: Hello, world.',
296
+ );
297
+
298
+ const secondInput = screen.getByTestId('do-input');
299
+ await userEvent.type(secondInput, '{Enter}');
300
+
301
+ await screen.findByTestId('message-2');
302
+ expect(screen.getByTestId('message-2')).toHaveTextContent(
303
+ 'AI: How can I help you?',
304
+ );
305
+ },
306
+ ),
307
+ );
284
308
  });
285
309
 
286
310
  describe('prepareRequestBody', () => {
@@ -330,30 +354,35 @@ describe('prepareRequestBody', () => {
330
354
  cleanup();
331
355
  });
332
356
 
333
- it('should show streamed response', async () => {
334
- const { requestBody } = mockFetchDataStream({
335
- url: 'https://example.com/api/chat',
336
- chunks: ['0:"Hello"\n', '0:","\n', '0:" world"\n', '0:"."\n'],
337
- });
338
-
339
- await userEvent.click(screen.getByTestId('do-append'));
357
+ it(
358
+ 'should show streamed response',
359
+ withTestServer(
360
+ {
361
+ url: '/api/chat',
362
+ type: 'stream-values',
363
+ content: ['0:"Hello"\n', '0:","\n', '0:" world"\n', '0:"."\n'],
364
+ },
365
+ async ({ call }) => {
366
+ await userEvent.click(screen.getByTestId('do-append'));
340
367
 
341
- await screen.findByTestId('message-0');
342
- expect(screen.getByTestId('message-0')).toHaveTextContent('User: hi');
368
+ await screen.findByTestId('message-0');
369
+ expect(screen.getByTestId('message-0')).toHaveTextContent('User: hi');
343
370
 
344
- expect(bodyOptions).toStrictEqual({
345
- messages: [{ role: 'user', content: 'hi', id: expect.any(String) }],
346
- requestData: { 'test-data-key': 'test-data-value' },
347
- requestBody: { 'request-body-key': 'request-body-value' },
348
- });
371
+ expect(bodyOptions).toStrictEqual({
372
+ messages: [{ role: 'user', content: 'hi', id: expect.any(String) }],
373
+ requestData: { 'test-data-key': 'test-data-value' },
374
+ requestBody: { 'request-body-key': 'request-body-value' },
375
+ });
349
376
 
350
- expect(await requestBody).toBe('"test-request-body"');
377
+ expect(await call(0).getRequestBodyJson()).toBe('test-request-body');
351
378
 
352
- await screen.findByTestId('message-1');
353
- expect(screen.getByTestId('message-1')).toHaveTextContent(
354
- 'AI: Hello, world.',
355
- );
356
- });
379
+ await screen.findByTestId('message-1');
380
+ expect(screen.getByTestId('message-1')).toHaveTextContent(
381
+ 'AI: Hello, world.',
382
+ );
383
+ },
384
+ ),
385
+ );
357
386
  });
358
387
 
359
388
  describe('onToolCall', () => {
@@ -399,36 +428,203 @@ describe('onToolCall', () => {
399
428
  cleanup();
400
429
  });
401
430
 
402
- it("should invoke onToolCall when a tool call is received from the server's response", async () => {
403
- mockFetchDataStream({
404
- url: 'https://example.com/api/chat',
405
- chunks: [
406
- formatStreamPart('tool_call', {
407
- toolCallId: 'tool-call-0',
408
- toolName: 'test-tool',
409
- args: { testArg: 'test-value' },
410
- }),
411
- ],
412
- });
431
+ it(
432
+ "should invoke onToolCall when a tool call is received from the server's response",
433
+ withTestServer(
434
+ {
435
+ url: '/api/chat',
436
+ type: 'stream-values',
437
+ content: [
438
+ formatStreamPart('tool_call', {
439
+ toolCallId: 'tool-call-0',
440
+ toolName: 'test-tool',
441
+ args: { testArg: 'test-value' },
442
+ }),
443
+ ],
444
+ },
445
+ async () => {
446
+ await userEvent.click(screen.getByTestId('do-append'));
413
447
 
414
- await userEvent.click(screen.getByTestId('do-append'));
448
+ await screen.findByTestId('message-1');
449
+ expect(screen.getByTestId('message-1')).toHaveTextContent(
450
+ 'test-tool-response: test-tool tool-call-0 {"testArg":"test-value"}',
451
+ );
452
+ },
453
+ ),
454
+ );
455
+ });
456
+
457
+ describe('tool invocations', () => {
458
+ let rerender: RenderResult['rerender'];
459
+
460
+ const TestComponent = () => {
461
+ const { messages, append } = useChat();
415
462
 
416
- await screen.findByTestId('message-1');
417
- expect(screen.getByTestId('message-1')).toHaveTextContent(
418
- 'test-tool-response: test-tool tool-call-0 {"testArg":"test-value"}',
463
+ return (
464
+ <div>
465
+ {messages.map((m, idx) => (
466
+ <div data-testid={`message-${idx}`} key={m.id}>
467
+ {m.toolInvocations?.map((toolInvocation, toolIdx) => {
468
+ return (
469
+ <div key={toolIdx} data-testid={`tool-invocation-${toolIdx}`}>
470
+ {JSON.stringify(toolInvocation)}
471
+ </div>
472
+ );
473
+ })}
474
+ </div>
475
+ ))}
476
+
477
+ <button
478
+ data-testid="do-append"
479
+ onClick={() => {
480
+ append({ role: 'user', content: 'hi' });
481
+ }}
482
+ />
483
+ </div>
419
484
  );
485
+ };
486
+
487
+ beforeEach(() => {
488
+ const result = render(<TestComponent />);
489
+ rerender = result.rerender;
490
+ });
491
+
492
+ afterEach(() => {
493
+ vi.restoreAllMocks();
494
+ cleanup();
420
495
  });
496
+
497
+ it(
498
+ 'should display partial tool call, tool call, and tool result',
499
+ withTestServer(
500
+ { url: '/api/chat', type: 'controlled-stream' },
501
+ async ({ streamController }) => {
502
+ await userEvent.click(screen.getByTestId('do-append'));
503
+
504
+ streamController.enqueue(
505
+ formatStreamPart('tool_call_streaming_start', {
506
+ toolCallId: 'tool-call-0',
507
+ toolName: 'test-tool',
508
+ }),
509
+ );
510
+
511
+ await waitFor(() => {
512
+ expect(screen.getByTestId('message-1')).toHaveTextContent(
513
+ '{"state":"partial-call","toolCallId":"tool-call-0","toolName":"test-tool"}',
514
+ );
515
+ });
516
+
517
+ streamController.enqueue(
518
+ formatStreamPart('tool_call_delta', {
519
+ toolCallId: 'tool-call-0',
520
+ argsTextDelta: '{"testArg":"t',
521
+ }),
522
+ );
523
+
524
+ await waitFor(() => {
525
+ rerender(<TestComponent />);
526
+ expect(screen.getByTestId('message-1')).toHaveTextContent(
527
+ '{"state":"partial-call","toolCallId":"tool-call-0","toolName":"test-tool","args":{"testArg":"t"}}',
528
+ );
529
+ });
530
+
531
+ streamController.enqueue(
532
+ formatStreamPart('tool_call_delta', {
533
+ toolCallId: 'tool-call-0',
534
+ argsTextDelta: 'est-value"}}',
535
+ }),
536
+ );
537
+
538
+ await waitFor(() => {
539
+ rerender(<TestComponent />);
540
+ expect(screen.getByTestId('message-1')).toHaveTextContent(
541
+ '{"state":"partial-call","toolCallId":"tool-call-0","toolName":"test-tool","args":{"testArg":"test-value"}}',
542
+ );
543
+ });
544
+
545
+ streamController.enqueue(
546
+ formatStreamPart('tool_call', {
547
+ toolCallId: 'tool-call-0',
548
+ toolName: 'test-tool',
549
+ args: { testArg: 'test-value' },
550
+ }),
551
+ );
552
+
553
+ await waitFor(() => {
554
+ rerender(<TestComponent />);
555
+ expect(screen.getByTestId('message-1')).toHaveTextContent(
556
+ '{"state":"call","toolCallId":"tool-call-0","toolName":"test-tool","args":{"testArg":"test-value"}}',
557
+ );
558
+ });
559
+
560
+ streamController.enqueue(
561
+ formatStreamPart('tool_result', {
562
+ toolCallId: 'tool-call-0',
563
+ toolName: 'test-tool',
564
+ args: { testArg: 'test-value' },
565
+ result: 'test-result',
566
+ }),
567
+ );
568
+ streamController.close();
569
+
570
+ await waitFor(() => {
571
+ expect(screen.getByTestId('message-1')).toHaveTextContent(
572
+ '{"state":"result","toolCallId":"tool-call-0","toolName":"test-tool","args":{"testArg":"test-value"},"result":"test-result"}',
573
+ );
574
+ });
575
+ },
576
+ ),
577
+ );
578
+
579
+ it(
580
+ 'should display partial tool call and tool result (when there is no tool call streaming)',
581
+ withTestServer(
582
+ { url: '/api/chat', type: 'controlled-stream' },
583
+ async ({ streamController }) => {
584
+ await userEvent.click(screen.getByTestId('do-append'));
585
+
586
+ streamController.enqueue(
587
+ formatStreamPart('tool_call', {
588
+ toolCallId: 'tool-call-0',
589
+ toolName: 'test-tool',
590
+ args: { testArg: 'test-value' },
591
+ }),
592
+ );
593
+
594
+ await waitFor(() => {
595
+ expect(screen.getByTestId('message-1')).toHaveTextContent(
596
+ '{"state":"call","toolCallId":"tool-call-0","toolName":"test-tool","args":{"testArg":"test-value"}}',
597
+ );
598
+ });
599
+
600
+ streamController.enqueue(
601
+ formatStreamPart('tool_result', {
602
+ toolCallId: 'tool-call-0',
603
+ toolName: 'test-tool',
604
+ args: { testArg: 'test-value' },
605
+ result: 'test-result',
606
+ }),
607
+ );
608
+ streamController.close();
609
+
610
+ await waitFor(() => {
611
+ expect(screen.getByTestId('message-1')).toHaveTextContent(
612
+ '{"state":"result","toolCallId":"tool-call-0","toolName":"test-tool","args":{"testArg":"test-value"},"result":"test-result"}',
613
+ );
614
+ });
615
+ },
616
+ ),
617
+ );
421
618
  });
422
619
 
423
620
  describe('maxToolRoundtrips', () => {
424
621
  describe('single automatic tool roundtrip', () => {
622
+ let onToolCallInvoked = false;
623
+
425
624
  const TestComponent = () => {
426
625
  const { messages, append } = useChat({
427
626
  async onToolCall({ toolCall }) {
428
- mockFetchDataStream({
429
- url: 'https://example.com/api/chat',
430
- chunks: [formatStreamPart('text', 'final result')],
431
- });
627
+ onToolCallInvoked = true;
432
628
 
433
629
  return `test-tool-response: ${toolCall.toolName} ${
434
630
  toolCall.toolCallId
@@ -457,6 +653,7 @@ describe('maxToolRoundtrips', () => {
457
653
 
458
654
  beforeEach(() => {
459
655
  render(<TestComponent />);
656
+ onToolCallInvoked = false;
460
657
  });
461
658
 
462
659
  afterEach(() => {
@@ -464,35 +661,48 @@ describe('maxToolRoundtrips', () => {
464
661
  cleanup();
465
662
  });
466
663
 
467
- it('should automatically call api when tool call gets executed via onToolCall', async () => {
468
- mockFetchDataStream({
469
- url: 'https://example.com/api/chat',
470
- chunks: [
471
- formatStreamPart('tool_call', {
472
- toolCallId: 'tool-call-0',
473
- toolName: 'test-tool',
474
- args: { testArg: 'test-value' },
475
- }),
664
+ it(
665
+ 'should automatically call api when tool call gets executed via onToolCall',
666
+ withTestServer(
667
+ [
668
+ {
669
+ url: '/api/chat',
670
+ type: 'stream-values',
671
+ content: [
672
+ formatStreamPart('tool_call', {
673
+ toolCallId: 'tool-call-0',
674
+ toolName: 'test-tool',
675
+ args: { testArg: 'test-value' },
676
+ }),
677
+ ],
678
+ },
679
+ {
680
+ url: '/api/chat',
681
+ type: 'stream-values',
682
+ content: [formatStreamPart('text', 'final result')],
683
+ },
476
684
  ],
477
- });
685
+ async () => {
686
+ await userEvent.click(screen.getByTestId('do-append'));
478
687
 
479
- await userEvent.click(screen.getByTestId('do-append'));
688
+ expect(onToolCallInvoked).toBe(true);
480
689
 
481
- await screen.findByTestId('message-2');
482
- expect(screen.getByTestId('message-2')).toHaveTextContent('final result');
483
- });
690
+ await screen.findByTestId('message-2');
691
+ expect(screen.getByTestId('message-2')).toHaveTextContent(
692
+ 'final result',
693
+ );
694
+ },
695
+ ),
696
+ );
484
697
  });
485
698
 
486
699
  describe('single roundtrip with error response', () => {
700
+ let onToolCallCounter = 0;
701
+
487
702
  const TestComponent = () => {
488
703
  const { messages, append, error } = useChat({
489
704
  async onToolCall({ toolCall }) {
490
- mockFetchDataStream({
491
- url: 'https://example.com/api/chat',
492
- chunks: [formatStreamPart('error', 'some failure')],
493
- maxCalls: 1,
494
- });
495
-
705
+ onToolCallCounter++;
496
706
  return `test-tool-response: ${toolCall.toolName} ${
497
707
  toolCall.toolCallId
498
708
  } ${JSON.stringify(toolCall.args)}`;
@@ -528,6 +738,7 @@ describe('maxToolRoundtrips', () => {
528
738
 
529
739
  beforeEach(() => {
530
740
  render(<TestComponent />);
741
+ onToolCallCounter = 0;
531
742
  });
532
743
 
533
744
  afterEach(() => {
@@ -535,24 +746,359 @@ describe('maxToolRoundtrips', () => {
535
746
  cleanup();
536
747
  });
537
748
 
538
- it('should automatically call api when tool call gets executed via onToolCall', async () => {
539
- mockFetchDataStream({
540
- url: 'https://example.com/api/chat',
541
- chunks: [
542
- formatStreamPart('tool_call', {
543
- toolCallId: 'tool-call-0',
544
- toolName: 'test-tool',
545
- args: { testArg: 'test-value' },
546
- }),
749
+ it(
750
+ 'should automatically call api when tool call gets executed via onToolCall',
751
+ withTestServer(
752
+ [
753
+ {
754
+ url: '/api/chat',
755
+ type: 'stream-values',
756
+ content: [
757
+ formatStreamPart('tool_call', {
758
+ toolCallId: 'tool-call-0',
759
+ toolName: 'test-tool',
760
+ args: { testArg: 'test-value' },
761
+ }),
762
+ ],
763
+ },
764
+ {
765
+ url: '/api/chat',
766
+ type: 'error',
767
+ status: 400,
768
+ content: 'call failure',
769
+ },
547
770
  ],
548
- });
771
+ async () => {
772
+ await userEvent.click(screen.getByTestId('do-append'));
549
773
 
550
- await userEvent.click(screen.getByTestId('do-append'));
774
+ await screen.findByTestId('error');
775
+ expect(screen.getByTestId('error')).toHaveTextContent(
776
+ 'Error: call failure',
777
+ );
551
778
 
552
- await screen.findByTestId('error');
553
- expect(screen.getByTestId('error')).toHaveTextContent(
554
- 'Error: Too many calls',
555
- );
556
- });
779
+ expect(onToolCallCounter).toBe(1);
780
+ },
781
+ ),
782
+ );
557
783
  });
558
784
  });
785
+
786
+ describe('file attachments with data url', () => {
787
+ const TestComponent = () => {
788
+ const { messages, handleSubmit, handleInputChange, isLoading, input } =
789
+ useChat();
790
+
791
+ const [attachments, setAttachments] = useState<FileList | undefined>(
792
+ undefined,
793
+ );
794
+ const fileInputRef = useRef<HTMLInputElement>(null);
795
+
796
+ return (
797
+ <div>
798
+ {messages.map((m, idx) => (
799
+ <div data-testid={`message-${idx}`} key={m.id}>
800
+ {m.role === 'user' ? 'User: ' : 'AI: '}
801
+ {m.content}
802
+ {m.experimental_attachments?.map(attachment => {
803
+ if (attachment.contentType?.startsWith('image/')) {
804
+ return (
805
+ <img
806
+ key={attachment.name}
807
+ src={attachment.url}
808
+ alt={attachment.name}
809
+ data-testid={`attachment-${idx}`}
810
+ />
811
+ );
812
+ } else if (attachment.contentType?.startsWith('text/')) {
813
+ return (
814
+ <div key={attachment.name} data-testid={`attachment-${idx}`}>
815
+ {getTextFromDataUrl(attachment.url)}
816
+ </div>
817
+ );
818
+ }
819
+ })}
820
+ </div>
821
+ ))}
822
+
823
+ <form
824
+ onSubmit={event => {
825
+ handleSubmit(event, {
826
+ experimental_attachments: attachments,
827
+ });
828
+ setAttachments(undefined);
829
+ if (fileInputRef.current) {
830
+ fileInputRef.current.value = '';
831
+ }
832
+ }}
833
+ data-testid="chat-form"
834
+ >
835
+ <input
836
+ type="file"
837
+ onChange={event => {
838
+ if (event.target.files) {
839
+ setAttachments(event.target.files);
840
+ }
841
+ }}
842
+ multiple
843
+ ref={fileInputRef}
844
+ data-testid="file-input"
845
+ />
846
+ <input
847
+ value={input}
848
+ onChange={handleInputChange}
849
+ disabled={isLoading}
850
+ data-testid="message-input"
851
+ />
852
+ <button type="submit" data-testid="submit-button">
853
+ Send
854
+ </button>
855
+ </form>
856
+ </div>
857
+ );
858
+ };
859
+
860
+ beforeEach(() => {
861
+ render(<TestComponent />);
862
+ });
863
+
864
+ afterEach(() => {
865
+ vi.restoreAllMocks();
866
+ cleanup();
867
+ });
868
+
869
+ it(
870
+ 'should handle text file attachment and submission',
871
+ withTestServer(
872
+ {
873
+ url: '/api/chat',
874
+ type: 'stream-values',
875
+ content: ['0:"Response to message with text attachment"\n'],
876
+ },
877
+ async ({ call }) => {
878
+ const file = new File(['test file content'], 'test.txt', {
879
+ type: 'text/plain',
880
+ });
881
+
882
+ const fileInput = screen.getByTestId('file-input');
883
+ await userEvent.upload(fileInput, file);
884
+
885
+ const messageInput = screen.getByTestId('message-input');
886
+ await userEvent.type(messageInput, 'Message with text attachment');
887
+
888
+ const submitButton = screen.getByTestId('submit-button');
889
+ await userEvent.click(submitButton);
890
+
891
+ expect(await call(0).getRequestBodyJson()).toStrictEqual({
892
+ messages: [
893
+ {
894
+ role: 'user',
895
+ content: 'Message with text attachment',
896
+ experimental_attachments: [
897
+ {
898
+ name: 'test.txt',
899
+ contentType: 'text/plain',
900
+ url: 'data:text/plain;base64,dGVzdCBmaWxlIGNvbnRlbnQ=',
901
+ },
902
+ ],
903
+ },
904
+ ],
905
+ });
906
+
907
+ await screen.findByTestId('message-0');
908
+ expect(screen.getByTestId('message-0')).toHaveTextContent(
909
+ 'User: Message with text attachment',
910
+ );
911
+
912
+ await screen.findByTestId('attachment-0');
913
+ expect(screen.getByTestId('attachment-0')).toHaveTextContent(
914
+ 'test file content',
915
+ );
916
+
917
+ await screen.findByTestId('message-1');
918
+ expect(screen.getByTestId('message-1')).toHaveTextContent(
919
+ 'AI: Response to message with text attachment',
920
+ );
921
+ },
922
+ ),
923
+ );
924
+
925
+ it(
926
+ 'should handle image file attachment and submission',
927
+ withTestServer(
928
+ {
929
+ url: '/api/chat',
930
+ type: 'stream-values',
931
+ content: ['0:"Response to message with image attachment"\n'],
932
+ },
933
+ async ({ call }) => {
934
+ const file = new File(['test image content'], 'test.png', {
935
+ type: 'image/png',
936
+ });
937
+
938
+ const fileInput = screen.getByTestId('file-input');
939
+ await userEvent.upload(fileInput, file);
940
+
941
+ const messageInput = screen.getByTestId('message-input');
942
+ await userEvent.type(messageInput, 'Message with image attachment');
943
+
944
+ const submitButton = screen.getByTestId('submit-button');
945
+ await userEvent.click(submitButton);
946
+
947
+ expect(await call(0).getRequestBodyJson()).toStrictEqual({
948
+ messages: [
949
+ {
950
+ role: 'user',
951
+ content: 'Message with image attachment',
952
+ experimental_attachments: [
953
+ {
954
+ name: 'test.png',
955
+ contentType: 'image/png',
956
+ url: 'data:image/png;base64,dGVzdCBpbWFnZSBjb250ZW50',
957
+ },
958
+ ],
959
+ },
960
+ ],
961
+ });
962
+
963
+ await screen.findByTestId('message-0');
964
+ expect(screen.getByTestId('message-0')).toHaveTextContent(
965
+ 'User: Message with image attachment',
966
+ );
967
+
968
+ await screen.findByTestId('attachment-0');
969
+ expect(screen.getByTestId('attachment-0')).toHaveAttribute(
970
+ 'src',
971
+ expect.stringContaining('data:image/png;base64'),
972
+ );
973
+
974
+ await screen.findByTestId('message-1');
975
+ expect(screen.getByTestId('message-1')).toHaveTextContent(
976
+ 'AI: Response to message with image attachment',
977
+ );
978
+ },
979
+ ),
980
+ );
981
+ });
982
+
983
+ describe('file attachments with url', () => {
984
+ const TestComponent = () => {
985
+ const { messages, handleSubmit, handleInputChange, isLoading, input } =
986
+ useChat();
987
+
988
+ return (
989
+ <div>
990
+ {messages.map((m, idx) => (
991
+ <div data-testid={`message-${idx}`} key={m.id}>
992
+ {m.role === 'user' ? 'User: ' : 'AI: '}
993
+ {m.content}
994
+ {m.experimental_attachments?.map(attachment => {
995
+ if (attachment.contentType?.startsWith('image/')) {
996
+ return (
997
+ <img
998
+ key={attachment.name}
999
+ src={attachment.url}
1000
+ alt={attachment.name}
1001
+ data-testid={`attachment-${idx}`}
1002
+ />
1003
+ );
1004
+ } else if (attachment.contentType?.startsWith('text/')) {
1005
+ return (
1006
+ <div key={attachment.name} data-testid={`attachment-${idx}`}>
1007
+ {Buffer.from(
1008
+ attachment.url.split(',')[1],
1009
+ 'base64',
1010
+ ).toString('utf-8')}
1011
+ </div>
1012
+ );
1013
+ }
1014
+ })}
1015
+ </div>
1016
+ ))}
1017
+
1018
+ <form
1019
+ onSubmit={event => {
1020
+ handleSubmit(event, {
1021
+ experimental_attachments: [
1022
+ {
1023
+ name: 'test.png',
1024
+ contentType: 'image/png',
1025
+ url: 'https://example.com/image.png',
1026
+ },
1027
+ ],
1028
+ });
1029
+ }}
1030
+ data-testid="chat-form"
1031
+ >
1032
+ <input
1033
+ value={input}
1034
+ onChange={handleInputChange}
1035
+ disabled={isLoading}
1036
+ data-testid="message-input"
1037
+ />
1038
+ <button type="submit" data-testid="submit-button">
1039
+ Send
1040
+ </button>
1041
+ </form>
1042
+ </div>
1043
+ );
1044
+ };
1045
+
1046
+ beforeEach(() => {
1047
+ render(<TestComponent />);
1048
+ });
1049
+
1050
+ afterEach(() => {
1051
+ vi.restoreAllMocks();
1052
+ cleanup();
1053
+ });
1054
+
1055
+ it(
1056
+ 'should handle image file attachment and submission',
1057
+ withTestServer(
1058
+ {
1059
+ url: '/api/chat',
1060
+ type: 'stream-values',
1061
+ content: ['0:"Response to message with image attachment"\n'],
1062
+ },
1063
+ async ({ call }) => {
1064
+ const messageInput = screen.getByTestId('message-input');
1065
+ await userEvent.type(messageInput, 'Message with image attachment');
1066
+
1067
+ const submitButton = screen.getByTestId('submit-button');
1068
+ await userEvent.click(submitButton);
1069
+
1070
+ expect(await call(0).getRequestBodyJson()).toStrictEqual({
1071
+ messages: [
1072
+ {
1073
+ role: 'user',
1074
+ content: 'Message with image attachment',
1075
+ experimental_attachments: [
1076
+ {
1077
+ name: 'test.png',
1078
+ contentType: 'image/png',
1079
+ url: 'https://example.com/image.png',
1080
+ },
1081
+ ],
1082
+ },
1083
+ ],
1084
+ });
1085
+
1086
+ await screen.findByTestId('message-0');
1087
+ expect(screen.getByTestId('message-0')).toHaveTextContent(
1088
+ 'User: Message with image attachment',
1089
+ );
1090
+
1091
+ await screen.findByTestId('attachment-0');
1092
+ expect(screen.getByTestId('attachment-0')).toHaveAttribute(
1093
+ 'src',
1094
+ expect.stringContaining('https://example.com/image.png'),
1095
+ );
1096
+
1097
+ await screen.findByTestId('message-1');
1098
+ expect(screen.getByTestId('message-1')).toHaveTextContent(
1099
+ 'AI: Response to message with image attachment',
1100
+ );
1101
+ },
1102
+ ),
1103
+ );
1104
+ });