@ai-sdk/react 0.0.22 → 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,5 +1,5 @@
1
1
 
2
- > @ai-sdk/react@0.0.22 build /home/runner/work/ai/ai/packages/react
2
+ > @ai-sdk/react@0.0.23 build /home/runner/work/ai/ai/packages/react
3
3
  > tsup
4
4
 
5
5
  CLI Building entry: src/index.ts
@@ -11,11 +11,11 @@
11
11
  ESM Build start
12
12
  CJS dist/index.js 26.77 KB
13
13
  CJS dist/index.js.map 53.33 KB
14
- CJS ⚡️ Build success in 50ms
14
+ CJS ⚡️ Build success in 61ms
15
15
  ESM dist/index.mjs 24.29 KB
16
16
  ESM dist/index.mjs.map 53.26 KB
17
- ESM ⚡️ Build success in 52ms
17
+ ESM ⚡️ Build success in 69ms
18
18
  DTS Build start
19
- DTS ⚡️ Build success in 5293ms
19
+ DTS ⚡️ Build success in 5498ms
20
20
  DTS dist/index.d.ts 10.42 KB
21
21
  DTS dist/index.d.mts 10.42 KB
@@ -1,4 +1,4 @@
1
1
 
2
- > @ai-sdk/react@0.0.22 clean /home/runner/work/ai/ai/packages/react
2
+ > @ai-sdk/react@0.0.23 clean /home/runner/work/ai/ai/packages/react
3
3
  > rm -rf dist
4
4
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # @ai-sdk/react
2
2
 
3
+ ## 0.0.23
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [1f67fe49]
8
+ - @ai-sdk/ui-utils@0.0.15
9
+
3
10
  ## 0.0.22
4
11
 
5
12
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/react",
3
- "version": "0.0.22",
3
+ "version": "0.0.23",
4
4
  "license": "Apache-2.0",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -16,7 +16,7 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "@ai-sdk/provider-utils": "1.0.2",
19
- "@ai-sdk/ui-utils": "0.0.14",
19
+ "@ai-sdk/ui-utils": "0.0.15",
20
20
  "swr": "2.2.0"
21
21
  },
22
22
  "devDependencies": {
@@ -1,21 +1,18 @@
1
1
  /* eslint-disable @next/next/no-img-element */
2
- import {
3
- mockFetchDataStream,
4
- mockFetchDataStreamWithGenerator,
5
- mockFetchError,
6
- } from '@ai-sdk/ui-utils/test';
2
+ import { withTestServer } from '@ai-sdk/provider-utils/test';
3
+ import { formatStreamPart, getTextFromDataUrl } from '@ai-sdk/ui-utils';
7
4
  import '@testing-library/jest-dom/vitest';
8
5
  import {
9
- act,
6
+ RenderResult,
10
7
  cleanup,
11
8
  findByText,
12
9
  render,
13
10
  screen,
11
+ waitFor,
14
12
  } from '@testing-library/react';
15
13
  import userEvent from '@testing-library/user-event';
16
14
  import React, { useRef, useState } from 'react';
17
15
  import { useChat } from './use-chat';
18
- import { formatStreamPart, getTextFromDataUrl } from '@ai-sdk/ui-utils';
19
16
 
20
17
  describe('stream data stream', () => {
21
18
  const TestComponent = () => {
@@ -59,110 +56,127 @@ describe('stream data stream', () => {
59
56
  cleanup();
60
57
  });
61
58
 
62
- it('should show streamed response', async () => {
63
- mockFetchDataStream({
64
- url: 'https://example.com/api/chat',
65
- chunks: ['0:"Hello"\n', '0:","\n', '0:" world"\n', '0:"."\n'],
66
- });
67
-
68
- await userEvent.click(screen.getByTestId('do-append'));
69
-
70
- await screen.findByTestId('message-0');
71
- expect(screen.getByTestId('message-0')).toHaveTextContent('User: hi');
72
-
73
- await screen.findByTestId('message-1');
74
- expect(screen.getByTestId('message-1')).toHaveTextContent(
75
- 'AI: Hello, world.',
76
- );
77
- });
78
-
79
- it('should show streamed response with data', async () => {
80
- mockFetchDataStream({
81
- url: 'https://example.com/api/chat',
82
- chunks: ['2:[{"t1":"v1"}]\n', '0:"Hello"\n'],
83
- });
84
-
85
- await userEvent.click(screen.getByTestId('do-append'));
86
-
87
- await screen.findByTestId('data');
88
- 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'));
89
69
 
90
- await screen.findByTestId('message-1');
91
- expect(screen.getByTestId('message-1')).toHaveTextContent('AI: Hello');
92
- });
70
+ await screen.findByTestId('message-0');
71
+ expect(screen.getByTestId('message-0')).toHaveTextContent('User: hi');
93
72
 
94
- it('should show error response', async () => {
95
- 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'));
96
91
 
97
- await userEvent.click(screen.getByTestId('do-append'));
92
+ await screen.findByTestId('data');
93
+ expect(screen.getByTestId('data')).toHaveTextContent('[{"t1":"v1"}]');
98
94
 
99
- await screen.findByTestId('error');
100
- expect(screen.getByTestId('error')).toHaveTextContent('Error: Not found');
101
- });
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
+ );
102
115
 
103
116
  describe('loading state', () => {
104
- it('should show loading state', async () => {
105
- let finishGeneration: ((value?: unknown) => void) | undefined;
106
- const finishGenerationPromise = new Promise(resolve => {
107
- finishGeneration = resolve;
108
- });
109
-
110
- mockFetchDataStreamWithGenerator({
111
- url: 'https://example.com/api/chat',
112
- chunkGenerator: (async function* generate() {
113
- const encoder = new TextEncoder();
114
- yield encoder.encode('0:"Hello"\n');
115
- await finishGenerationPromise;
116
- })(),
117
- });
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');
118
123
 
119
- await userEvent.click(screen.getByTestId('do-append'));
124
+ await userEvent.click(screen.getByTestId('do-append'));
120
125
 
121
- await screen.findByTestId('loading');
122
- expect(screen.getByTestId('loading')).toHaveTextContent('true');
126
+ await screen.findByTestId('loading');
127
+ expect(screen.getByTestId('loading')).toHaveTextContent('true');
123
128
 
124
- finishGeneration?.();
129
+ streamController.close();
125
130
 
126
- await findByText(await screen.findByTestId('loading'), 'false');
127
- expect(screen.getByTestId('loading')).toHaveTextContent('false');
128
- });
129
-
130
- it('should reset loading state on error', async () => {
131
- mockFetchError({ statusCode: 404, errorMessage: 'Not found' });
131
+ await findByText(await screen.findByTestId('loading'), 'false');
132
+ expect(screen.getByTestId('loading')).toHaveTextContent('false');
133
+ },
134
+ ),
135
+ );
132
136
 
133
- 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'));
134
143
 
135
- await screen.findByTestId('loading');
136
- expect(screen.getByTestId('loading')).toHaveTextContent('false');
137
- });
144
+ await screen.findByTestId('loading');
145
+ expect(screen.getByTestId('loading')).toHaveTextContent('false');
146
+ },
147
+ ),
148
+ );
138
149
  });
139
150
 
140
151
  describe('id', () => {
141
- it('should clear out messages when the id changes', async () => {
142
- mockFetchDataStream({
143
- url: 'https://example.com/api/chat',
144
- chunks: ['0:"Hello"\n', '0:","\n', '0:" world"\n', '0:"."\n'],
145
- });
146
-
147
- await userEvent.click(screen.getByTestId('do-append'));
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'));
148
162
 
149
- await screen.findByTestId('message-1');
150
- expect(screen.getByTestId('message-1')).toHaveTextContent(
151
- 'AI: Hello, world.',
152
- );
163
+ await screen.findByTestId('message-1');
164
+ expect(screen.getByTestId('message-1')).toHaveTextContent(
165
+ 'AI: Hello, world.',
166
+ );
153
167
 
154
- await userEvent.click(screen.getByTestId('do-change-id'));
168
+ await userEvent.click(screen.getByTestId('do-change-id'));
155
169
 
156
- expect(screen.queryByTestId('message-0')).not.toBeInTheDocument();
157
- });
170
+ expect(screen.queryByTestId('message-0')).not.toBeInTheDocument();
171
+ },
172
+ ),
173
+ );
158
174
  });
159
175
  });
160
176
 
161
177
  describe('text stream', () => {
162
178
  const TestComponent = () => {
163
- const { messages, append } = useChat({
164
- streamMode: 'text',
165
- });
179
+ const { messages, append } = useChat({ streamMode: 'text' });
166
180
 
167
181
  return (
168
182
  <div>
@@ -192,38 +206,35 @@ describe('text stream', () => {
192
206
  cleanup();
193
207
  });
194
208
 
195
- it('should show streamed response', async () => {
196
- mockFetchDataStream({
197
- url: 'https://example.com/api/chat',
198
- chunks: ['Hello', ',', ' world', '.'],
199
- });
200
-
201
- await userEvent.click(screen.getByTestId('do-append-text-stream'));
202
-
203
- await screen.findByTestId('message-0-text-stream');
204
- expect(screen.getByTestId('message-0-text-stream')).toHaveTextContent(
205
- 'User: hi',
206
- );
207
-
208
- await screen.findByTestId('message-1-text-stream');
209
- expect(screen.getByTestId('message-1-text-stream')).toHaveTextContent(
210
- 'AI: Hello, world.',
211
- );
212
- });
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
+ );
213
232
  });
214
233
 
215
234
  describe('form actions', () => {
216
235
  const TestComponent = () => {
217
- const {
218
- messages,
219
- append,
220
- handleSubmit,
221
- handleInputChange,
222
- isLoading,
223
- input,
224
- } = useChat({
225
- streamMode: 'text',
226
- });
236
+ const { messages, handleSubmit, handleInputChange, isLoading, input } =
237
+ useChat({ streamMode: 'text' });
227
238
 
228
239
  return (
229
240
  <div>
@@ -256,37 +267,44 @@ describe('form actions', () => {
256
267
  cleanup();
257
268
  });
258
269
 
259
- it('should show streamed response using handleSubmit', async () => {
260
- mockFetchDataStream({
261
- url: 'https://example.com/api/chat',
262
- chunks: ['Hello', ',', ' world', '.'],
263
- });
264
-
265
- const firstInput = screen.getByTestId('do-input');
266
- await userEvent.type(firstInput, 'hi');
267
- await userEvent.keyboard('{Enter}');
268
-
269
- await screen.findByTestId('message-0');
270
- expect(screen.getByTestId('message-0')).toHaveTextContent('User: hi');
271
-
272
- await screen.findByTestId('message-1');
273
- expect(screen.getByTestId('message-1')).toHaveTextContent(
274
- 'AI: Hello, world.',
275
- );
276
-
277
- mockFetchDataStream({
278
- url: 'https://example.com/api/chat',
279
- chunks: ['How', ' can', ' I', ' help', ' you', '?'],
280
- });
281
-
282
- const secondInput = screen.getByTestId('do-input');
283
- await userEvent.type(secondInput, '{Enter}');
284
-
285
- await screen.findByTestId('message-2');
286
- expect(screen.getByTestId('message-2')).toHaveTextContent(
287
- 'AI: How can I help you?',
288
- );
289
- });
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
+ );
290
308
  });
291
309
 
292
310
  describe('prepareRequestBody', () => {
@@ -336,30 +354,35 @@ describe('prepareRequestBody', () => {
336
354
  cleanup();
337
355
  });
338
356
 
339
- it('should show streamed response', async () => {
340
- const { requestBody } = mockFetchDataStream({
341
- url: 'https://example.com/api/chat',
342
- chunks: ['0:"Hello"\n', '0:","\n', '0:" world"\n', '0:"."\n'],
343
- });
344
-
345
- 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'));
346
367
 
347
- await screen.findByTestId('message-0');
348
- expect(screen.getByTestId('message-0')).toHaveTextContent('User: hi');
368
+ await screen.findByTestId('message-0');
369
+ expect(screen.getByTestId('message-0')).toHaveTextContent('User: hi');
349
370
 
350
- expect(bodyOptions).toStrictEqual({
351
- messages: [{ role: 'user', content: 'hi', id: expect.any(String) }],
352
- requestData: { 'test-data-key': 'test-data-value' },
353
- requestBody: { 'request-body-key': 'request-body-value' },
354
- });
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
+ });
355
376
 
356
- expect(await requestBody).toBe('"test-request-body"');
377
+ expect(await call(0).getRequestBodyJson()).toBe('test-request-body');
357
378
 
358
- await screen.findByTestId('message-1');
359
- expect(screen.getByTestId('message-1')).toHaveTextContent(
360
- 'AI: Hello, world.',
361
- );
362
- });
379
+ await screen.findByTestId('message-1');
380
+ expect(screen.getByTestId('message-1')).toHaveTextContent(
381
+ 'AI: Hello, world.',
382
+ );
383
+ },
384
+ ),
385
+ );
363
386
  });
364
387
 
365
388
  describe('onToolCall', () => {
@@ -405,36 +428,203 @@ describe('onToolCall', () => {
405
428
  cleanup();
406
429
  });
407
430
 
408
- it("should invoke onToolCall when a tool call is received from the server's response", async () => {
409
- mockFetchDataStream({
410
- url: 'https://example.com/api/chat',
411
- chunks: [
412
- formatStreamPart('tool_call', {
413
- toolCallId: 'tool-call-0',
414
- toolName: 'test-tool',
415
- args: { testArg: 'test-value' },
416
- }),
417
- ],
418
- });
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'));
419
447
 
420
- 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();
421
462
 
422
- await screen.findByTestId('message-1');
423
- expect(screen.getByTestId('message-1')).toHaveTextContent(
424
- '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>
425
484
  );
485
+ };
486
+
487
+ beforeEach(() => {
488
+ const result = render(<TestComponent />);
489
+ rerender = result.rerender;
490
+ });
491
+
492
+ afterEach(() => {
493
+ vi.restoreAllMocks();
494
+ cleanup();
426
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
+ );
427
618
  });
428
619
 
429
620
  describe('maxToolRoundtrips', () => {
430
621
  describe('single automatic tool roundtrip', () => {
622
+ let onToolCallInvoked = false;
623
+
431
624
  const TestComponent = () => {
432
625
  const { messages, append } = useChat({
433
626
  async onToolCall({ toolCall }) {
434
- mockFetchDataStream({
435
- url: 'https://example.com/api/chat',
436
- chunks: [formatStreamPart('text', 'final result')],
437
- });
627
+ onToolCallInvoked = true;
438
628
 
439
629
  return `test-tool-response: ${toolCall.toolName} ${
440
630
  toolCall.toolCallId
@@ -463,6 +653,7 @@ describe('maxToolRoundtrips', () => {
463
653
 
464
654
  beforeEach(() => {
465
655
  render(<TestComponent />);
656
+ onToolCallInvoked = false;
466
657
  });
467
658
 
468
659
  afterEach(() => {
@@ -470,35 +661,48 @@ describe('maxToolRoundtrips', () => {
470
661
  cleanup();
471
662
  });
472
663
 
473
- it('should automatically call api when tool call gets executed via onToolCall', async () => {
474
- mockFetchDataStream({
475
- url: 'https://example.com/api/chat',
476
- chunks: [
477
- formatStreamPart('tool_call', {
478
- toolCallId: 'tool-call-0',
479
- toolName: 'test-tool',
480
- args: { testArg: 'test-value' },
481
- }),
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
+ },
482
684
  ],
483
- });
685
+ async () => {
686
+ await userEvent.click(screen.getByTestId('do-append'));
484
687
 
485
- await userEvent.click(screen.getByTestId('do-append'));
688
+ expect(onToolCallInvoked).toBe(true);
486
689
 
487
- await screen.findByTestId('message-2');
488
- expect(screen.getByTestId('message-2')).toHaveTextContent('final result');
489
- });
690
+ await screen.findByTestId('message-2');
691
+ expect(screen.getByTestId('message-2')).toHaveTextContent(
692
+ 'final result',
693
+ );
694
+ },
695
+ ),
696
+ );
490
697
  });
491
698
 
492
699
  describe('single roundtrip with error response', () => {
700
+ let onToolCallCounter = 0;
701
+
493
702
  const TestComponent = () => {
494
703
  const { messages, append, error } = useChat({
495
704
  async onToolCall({ toolCall }) {
496
- mockFetchDataStream({
497
- url: 'https://example.com/api/chat',
498
- chunks: [formatStreamPart('error', 'some failure')],
499
- maxCalls: 1,
500
- });
501
-
705
+ onToolCallCounter++;
502
706
  return `test-tool-response: ${toolCall.toolName} ${
503
707
  toolCall.toolCallId
504
708
  } ${JSON.stringify(toolCall.args)}`;
@@ -534,6 +738,7 @@ describe('maxToolRoundtrips', () => {
534
738
 
535
739
  beforeEach(() => {
536
740
  render(<TestComponent />);
741
+ onToolCallCounter = 0;
537
742
  });
538
743
 
539
744
  afterEach(() => {
@@ -541,34 +746,47 @@ describe('maxToolRoundtrips', () => {
541
746
  cleanup();
542
747
  });
543
748
 
544
- it('should automatically call api when tool call gets executed via onToolCall', async () => {
545
- mockFetchDataStream({
546
- url: 'https://example.com/api/chat',
547
- chunks: [
548
- formatStreamPart('tool_call', {
549
- toolCallId: 'tool-call-0',
550
- toolName: 'test-tool',
551
- args: { testArg: 'test-value' },
552
- }),
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
+ },
553
770
  ],
554
- });
771
+ async () => {
772
+ await userEvent.click(screen.getByTestId('do-append'));
555
773
 
556
- await userEvent.click(screen.getByTestId('do-append'));
774
+ await screen.findByTestId('error');
775
+ expect(screen.getByTestId('error')).toHaveTextContent(
776
+ 'Error: call failure',
777
+ );
557
778
 
558
- await screen.findByTestId('error');
559
- expect(screen.getByTestId('error')).toHaveTextContent(
560
- 'Error: Too many calls',
561
- );
562
- });
779
+ expect(onToolCallCounter).toBe(1);
780
+ },
781
+ ),
782
+ );
563
783
  });
564
784
  });
565
785
 
566
786
  describe('file attachments with data url', () => {
567
787
  const TestComponent = () => {
568
788
  const { messages, handleSubmit, handleInputChange, isLoading, input } =
569
- useChat({
570
- api: '/api/stream-chat',
571
- });
789
+ useChat();
572
790
 
573
791
  const [attachments, setAttachments] = useState<FileList | undefined>(
574
792
  undefined,
@@ -648,102 +866,124 @@ describe('file attachments with data url', () => {
648
866
  cleanup();
649
867
  });
650
868
 
651
- it('should handle text file attachment and submission', async () => {
652
- const file = new File(['test file content'], 'test.txt', {
653
- type: 'text/plain',
654
- });
655
-
656
- const { requestBody } = mockFetchDataStream({
657
- url: '/api/stream-chat',
658
- chunks: ['0:"Response to message with text attachment"\n'],
659
- });
660
-
661
- const fileInput = screen.getByTestId('file-input');
662
- await userEvent.upload(fileInput, file);
663
-
664
- const messageInput = screen.getByTestId('message-input');
665
- await userEvent.type(messageInput, 'Message with text attachment');
666
-
667
- const submitButton = screen.getByTestId('submit-button');
668
- await userEvent.click(submitButton);
669
-
670
- const sentBody = JSON.parse((await requestBody) as string);
671
- expect(sentBody.messages[0].content).toBe('Message with text attachment');
672
- expect(sentBody.messages[0].experimental_attachments).toBeDefined();
673
- expect(sentBody.messages[0].experimental_attachments.length).toBe(1);
674
- expect(sentBody.messages[0].experimental_attachments[0].name).toBe(
675
- 'test.txt',
676
- );
677
-
678
- await screen.findByTestId('message-0');
679
- expect(screen.getByTestId('message-0')).toHaveTextContent(
680
- 'User: Message with text attachment',
681
- );
682
-
683
- await screen.findByTestId('attachment-0');
684
- expect(screen.getByTestId('attachment-0')).toHaveTextContent(
685
- 'test file content',
686
- );
687
-
688
- await screen.findByTestId('message-1');
689
- expect(screen.getByTestId('message-1')).toHaveTextContent(
690
- 'AI: Response to message with text attachment',
691
- );
692
- });
693
-
694
- // image file
695
-
696
- it('should handle image file attachment and submission', async () => {
697
- const file = new File(['test image content'], 'test.png', {
698
- type: 'image/png',
699
- });
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
+ });
700
881
 
701
- const { requestBody } = mockFetchDataStream({
702
- url: '/api/stream-chat',
703
- chunks: ['0:"Response to message with image attachment"\n'],
704
- });
882
+ const fileInput = screen.getByTestId('file-input');
883
+ await userEvent.upload(fileInput, file);
705
884
 
706
- const fileInput = screen.getByTestId('file-input');
707
- await userEvent.upload(fileInput, file);
885
+ const messageInput = screen.getByTestId('message-input');
886
+ await userEvent.type(messageInput, 'Message with text attachment');
708
887
 
709
- const messageInput = screen.getByTestId('message-input');
710
- await userEvent.type(messageInput, 'Message with image attachment');
888
+ const submitButton = screen.getByTestId('submit-button');
889
+ await userEvent.click(submitButton);
711
890
 
712
- const submitButton = screen.getByTestId('submit-button');
713
- await userEvent.click(submitButton);
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
+ });
714
937
 
715
- const sentBody = JSON.parse((await requestBody) as string);
716
- expect(sentBody.messages[0].content).toBe('Message with image attachment');
717
- expect(sentBody.messages[0].experimental_attachments).toBeDefined();
718
- expect(sentBody.messages[0].experimental_attachments.length).toBe(1);
719
- expect(sentBody.messages[0].experimental_attachments[0].name).toBe(
720
- 'test.png',
721
- );
938
+ const fileInput = screen.getByTestId('file-input');
939
+ await userEvent.upload(fileInput, file);
722
940
 
723
- await screen.findByTestId('message-0');
724
- expect(screen.getByTestId('message-0')).toHaveTextContent(
725
- 'User: Message with image attachment',
726
- );
941
+ const messageInput = screen.getByTestId('message-input');
942
+ await userEvent.type(messageInput, 'Message with image attachment');
727
943
 
728
- await screen.findByTestId('attachment-0');
729
- expect(screen.getByTestId('attachment-0')).toHaveAttribute(
730
- 'src',
731
- expect.stringContaining('data:image/png;base64'),
732
- );
944
+ const submitButton = screen.getByTestId('submit-button');
945
+ await userEvent.click(submitButton);
733
946
 
734
- await screen.findByTestId('message-1');
735
- expect(screen.getByTestId('message-1')).toHaveTextContent(
736
- 'AI: Response to message with image attachment',
737
- );
738
- });
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
+ );
739
981
  });
740
982
 
741
983
  describe('file attachments with url', () => {
742
984
  const TestComponent = () => {
743
985
  const { messages, handleSubmit, handleInputChange, isLoading, input } =
744
- useChat({
745
- api: '/api/stream-chat',
746
- });
986
+ useChat();
747
987
 
748
988
  return (
749
989
  <div>
@@ -812,40 +1052,53 @@ describe('file attachments with url', () => {
812
1052
  cleanup();
813
1053
  });
814
1054
 
815
- it('should handle image file attachment and submission', async () => {
816
- const { requestBody } = mockFetchDataStream({
817
- url: '/api/stream-chat',
818
- chunks: ['0:"Response to message with image attachment"\n'],
819
- });
820
-
821
- const messageInput = screen.getByTestId('message-input');
822
- await userEvent.type(messageInput, 'Message with image attachment');
823
-
824
- const submitButton = screen.getByTestId('submit-button');
825
- await userEvent.click(submitButton);
826
-
827
- const sentBody = JSON.parse((await requestBody) as string);
828
- expect(sentBody.messages[0].content).toBe('Message with image attachment');
829
- expect(sentBody.messages[0].experimental_attachments).toBeDefined();
830
- expect(sentBody.messages[0].experimental_attachments.length).toBe(1);
831
- expect(sentBody.messages[0].experimental_attachments[0].name).toBe(
832
- 'test.png',
833
- );
834
-
835
- await screen.findByTestId('message-0');
836
- expect(screen.getByTestId('message-0')).toHaveTextContent(
837
- 'User: Message with image attachment',
838
- );
839
-
840
- await screen.findByTestId('attachment-0');
841
- expect(screen.getByTestId('attachment-0')).toHaveAttribute(
842
- 'src',
843
- expect.stringContaining('https://example.com/image.png'),
844
- );
845
-
846
- await screen.findByTestId('message-1');
847
- expect(screen.getByTestId('message-1')).toHaveTextContent(
848
- 'AI: Response to message with image attachment',
849
- );
850
- });
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
+ );
851
1104
  });