@cloudflare/ai-chat 0.0.1 → 0.0.3

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.
@@ -0,0 +1,612 @@
1
+ import { StrictMode, Suspense, act } from "react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { render } from "vitest-browser-react";
4
+ import type { UIMessage } from "ai";
5
+ import {
6
+ useAgentChat,
7
+ type PrepareSendMessagesRequestOptions,
8
+ type PrepareSendMessagesRequestResult,
9
+ type AITool
10
+ } from "../react";
11
+ import type { useAgent } from "agents/react";
12
+
13
+ function createAgent({ name, url }: { name: string; url: string }) {
14
+ const target = new EventTarget();
15
+ const baseAgent = {
16
+ _pkurl: url,
17
+ _url: null as string | null,
18
+ addEventListener: target.addEventListener.bind(target),
19
+ agent: "Chat",
20
+ close: () => {},
21
+ id: "fake-agent",
22
+ name,
23
+ removeEventListener: target.removeEventListener.bind(target),
24
+ send: () => {},
25
+ dispatchEvent: target.dispatchEvent.bind(target)
26
+ };
27
+ return baseAgent as unknown as ReturnType<typeof useAgent>;
28
+ }
29
+
30
+ describe("useAgentChat", () => {
31
+ it("should cache initial message responses across re-renders", async () => {
32
+ const agent = createAgent({
33
+ name: "thread-alpha",
34
+ url: "ws://localhost:3000/agents/chat/thread-alpha?_pk=abc"
35
+ });
36
+
37
+ const testMessages = [
38
+ {
39
+ id: "1",
40
+ role: "user" as const,
41
+ parts: [{ type: "text" as const, text: "Hi" }]
42
+ },
43
+ {
44
+ id: "2",
45
+ role: "assistant" as const,
46
+ parts: [{ type: "text" as const, text: "Hello" }]
47
+ }
48
+ ];
49
+
50
+ const getInitialMessages = vi.fn(() => Promise.resolve(testMessages));
51
+
52
+ const TestComponent = () => {
53
+ const chat = useAgentChat({
54
+ agent,
55
+ getInitialMessages
56
+ });
57
+ return <div data-testid="messages">{JSON.stringify(chat.messages)}</div>;
58
+ };
59
+
60
+ const suspenseRendered = vi.fn();
61
+ const SuspenseObserver = () => {
62
+ suspenseRendered();
63
+ return "Suspended";
64
+ };
65
+
66
+ const screen = await act(() =>
67
+ render(<TestComponent />, {
68
+ wrapper: ({ children }) => (
69
+ <StrictMode>
70
+ <Suspense fallback={<SuspenseObserver />}>{children}</Suspense>
71
+ </StrictMode>
72
+ )
73
+ })
74
+ );
75
+
76
+ await expect
77
+ .element(screen.getByTestId("messages"))
78
+ .toHaveTextContent(JSON.stringify(testMessages));
79
+
80
+ expect(getInitialMessages).toHaveBeenCalledTimes(1);
81
+ expect(suspenseRendered).toHaveBeenCalled();
82
+
83
+ suspenseRendered.mockClear();
84
+
85
+ await screen.rerender(<TestComponent />);
86
+
87
+ await expect
88
+ .element(screen.getByTestId("messages"))
89
+ .toHaveTextContent(JSON.stringify(testMessages));
90
+
91
+ expect(getInitialMessages).toHaveBeenCalledTimes(1);
92
+ expect(suspenseRendered).not.toHaveBeenCalled();
93
+ });
94
+
95
+ it("should refetch initial messages when the agent name changes", async () => {
96
+ const url = "ws://localhost:3000/agents/chat/thread-a?_pk=abc";
97
+ const agentA = createAgent({ name: "thread-a", url });
98
+ const agentB = createAgent({ name: "thread-b", url });
99
+
100
+ const getInitialMessages = vi.fn(async ({ name }: { name: string }) => [
101
+ {
102
+ id: "1",
103
+ role: "assistant" as const,
104
+ parts: [{ type: "text" as const, text: `Hello from ${name}` }]
105
+ }
106
+ ]);
107
+
108
+ const TestComponent = ({
109
+ agent
110
+ }: {
111
+ agent: ReturnType<typeof useAgent>;
112
+ }) => {
113
+ const chat = useAgentChat({
114
+ agent,
115
+ getInitialMessages
116
+ });
117
+ return <div data-testid="messages">{JSON.stringify(chat.messages)}</div>;
118
+ };
119
+
120
+ const suspenseRendered = vi.fn();
121
+ const SuspenseObserver = () => {
122
+ suspenseRendered();
123
+ return "Suspended";
124
+ };
125
+
126
+ const screen = await act(() =>
127
+ render(<TestComponent agent={agentA} />, {
128
+ wrapper: ({ children }) => (
129
+ <StrictMode>
130
+ <Suspense fallback={<SuspenseObserver />}>{children}</Suspense>
131
+ </StrictMode>
132
+ )
133
+ })
134
+ );
135
+
136
+ await expect
137
+ .element(screen.getByTestId("messages"))
138
+ .toHaveTextContent("Hello from thread-a");
139
+
140
+ expect(getInitialMessages).toHaveBeenCalledTimes(1);
141
+ expect(getInitialMessages).toHaveBeenNthCalledWith(
142
+ 1,
143
+ expect.objectContaining({ name: "thread-a" })
144
+ );
145
+
146
+ suspenseRendered.mockClear();
147
+
148
+ await act(() => screen.rerender(<TestComponent agent={agentB} />));
149
+
150
+ await expect
151
+ .element(screen.getByTestId("messages"))
152
+ .toHaveTextContent("Hello from thread-b");
153
+
154
+ expect(getInitialMessages).toHaveBeenCalledTimes(2);
155
+ expect(getInitialMessages).toHaveBeenNthCalledWith(
156
+ 2,
157
+ expect.objectContaining({ name: "thread-b" })
158
+ );
159
+ });
160
+
161
+ it("should accept prepareSendMessagesRequest option without errors", async () => {
162
+ const agent = createAgent({
163
+ name: "thread-with-tools",
164
+ url: "ws://localhost:3000/agents/chat/thread-with-tools?_pk=abc"
165
+ });
166
+
167
+ const prepareSendMessagesRequest = vi.fn(
168
+ (
169
+ _options: PrepareSendMessagesRequestOptions<UIMessage>
170
+ ): PrepareSendMessagesRequestResult => ({
171
+ body: {
172
+ clientTools: [
173
+ {
174
+ name: "showAlert",
175
+ description: "Shows an alert to the user",
176
+ parameters: { message: { type: "string" } }
177
+ }
178
+ ]
179
+ },
180
+ headers: {
181
+ "X-Client-Tool-Count": "1"
182
+ }
183
+ })
184
+ );
185
+
186
+ const TestComponent = () => {
187
+ const chat = useAgentChat({
188
+ agent,
189
+ getInitialMessages: null, // Skip fetching initial messages
190
+ prepareSendMessagesRequest
191
+ });
192
+ return <div data-testid="messages-count">{chat.messages.length}</div>;
193
+ };
194
+
195
+ const screen = await act(() =>
196
+ render(<TestComponent />, {
197
+ wrapper: ({ children }) => (
198
+ <StrictMode>
199
+ <Suspense fallback="Loading...">{children}</Suspense>
200
+ </StrictMode>
201
+ )
202
+ })
203
+ );
204
+
205
+ // Verify component renders without errors
206
+ await expect
207
+ .element(screen.getByTestId("messages-count"))
208
+ .toHaveTextContent("0");
209
+ });
210
+
211
+ it("should handle async prepareSendMessagesRequest", async () => {
212
+ const agent = createAgent({
213
+ name: "thread-async-prepare",
214
+ url: "ws://localhost:3000/agents/chat/thread-async-prepare?_pk=abc"
215
+ });
216
+
217
+ const prepareSendMessagesRequest = vi.fn(
218
+ async (
219
+ _options: PrepareSendMessagesRequestOptions<UIMessage>
220
+ ): Promise<PrepareSendMessagesRequestResult> => {
221
+ // Simulate async operation like fetching tool definitions
222
+ await new Promise((resolve) => setTimeout(resolve, 10));
223
+ return {
224
+ body: {
225
+ clientTools: [
226
+ { name: "navigateToPage", description: "Navigates to a page" }
227
+ ]
228
+ }
229
+ };
230
+ }
231
+ );
232
+
233
+ const TestComponent = () => {
234
+ const chat = useAgentChat({
235
+ agent,
236
+ getInitialMessages: null,
237
+ prepareSendMessagesRequest
238
+ });
239
+ return <div data-testid="messages-count">{chat.messages.length}</div>;
240
+ };
241
+
242
+ const screen = await act(() =>
243
+ render(<TestComponent />, {
244
+ wrapper: ({ children }) => (
245
+ <StrictMode>
246
+ <Suspense fallback="Loading...">{children}</Suspense>
247
+ </StrictMode>
248
+ )
249
+ })
250
+ );
251
+
252
+ // Verify component renders without errors
253
+ await expect
254
+ .element(screen.getByTestId("messages-count"))
255
+ .toHaveTextContent("0");
256
+ });
257
+
258
+ it("should auto-extract schemas from tools with execute functions", async () => {
259
+ const agent = createAgent({
260
+ name: "thread-client-tools",
261
+ url: "ws://localhost:3000/agents/chat/thread-client-tools?_pk=abc"
262
+ });
263
+
264
+ // Tools with execute functions have their schemas auto-extracted and sent to server
265
+ const tools: Record<string, AITool<unknown, unknown>> = {
266
+ showAlert: {
267
+ description: "Shows an alert dialog to the user",
268
+ parameters: {
269
+ type: "object",
270
+ properties: {
271
+ message: { type: "string", description: "The message to display" }
272
+ },
273
+ required: ["message"]
274
+ },
275
+ execute: async (input) => {
276
+ // Client-side execution
277
+ const { message } = input as { message: string };
278
+ return { shown: true, message };
279
+ }
280
+ },
281
+ changeBackgroundColor: {
282
+ description: "Changes the page background color",
283
+ parameters: {
284
+ type: "object",
285
+ properties: {
286
+ color: { type: "string" }
287
+ }
288
+ },
289
+ execute: async (input) => {
290
+ const { color } = input as { color: string };
291
+ return { success: true, color };
292
+ }
293
+ }
294
+ };
295
+
296
+ const TestComponent = () => {
297
+ const chat = useAgentChat({
298
+ agent,
299
+ getInitialMessages: null,
300
+ tools
301
+ });
302
+ return <div data-testid="messages-count">{chat.messages.length}</div>;
303
+ };
304
+
305
+ const screen = await act(() =>
306
+ render(<TestComponent />, {
307
+ wrapper: ({ children }) => (
308
+ <StrictMode>
309
+ <Suspense fallback="Loading...">{children}</Suspense>
310
+ </StrictMode>
311
+ )
312
+ })
313
+ );
314
+
315
+ // Verify component renders without errors
316
+ await expect
317
+ .element(screen.getByTestId("messages-count"))
318
+ .toHaveTextContent("0");
319
+ });
320
+
321
+ it("should combine auto-extracted tools with prepareSendMessagesRequest", async () => {
322
+ const agent = createAgent({
323
+ name: "thread-combined",
324
+ url: "ws://localhost:3000/agents/chat/thread-combined?_pk=abc"
325
+ });
326
+
327
+ const tools: Record<string, AITool> = {
328
+ showAlert: {
329
+ description: "Shows an alert",
330
+ execute: async () => ({ shown: true })
331
+ }
332
+ };
333
+
334
+ const prepareSendMessagesRequest = vi.fn(
335
+ (
336
+ _options: PrepareSendMessagesRequestOptions<UIMessage>
337
+ ): PrepareSendMessagesRequestResult => ({
338
+ body: {
339
+ customData: "extra-context",
340
+ userTimezone: "America/New_York"
341
+ },
342
+ headers: {
343
+ "X-Custom-Header": "custom-value"
344
+ }
345
+ })
346
+ );
347
+
348
+ const TestComponent = () => {
349
+ const chat = useAgentChat({
350
+ agent,
351
+ getInitialMessages: null,
352
+ tools,
353
+ prepareSendMessagesRequest
354
+ });
355
+ return <div data-testid="messages-count">{chat.messages.length}</div>;
356
+ };
357
+
358
+ const screen = await act(() =>
359
+ render(<TestComponent />, {
360
+ wrapper: ({ children }) => (
361
+ <StrictMode>
362
+ <Suspense fallback="Loading...">{children}</Suspense>
363
+ </StrictMode>
364
+ )
365
+ })
366
+ );
367
+
368
+ // Verify component renders without errors
369
+ await expect
370
+ .element(screen.getByTestId("messages-count"))
371
+ .toHaveTextContent("0");
372
+ });
373
+
374
+ it("should work with tools that have execute functions for client-side execution", async () => {
375
+ const agent = createAgent({
376
+ name: "thread-tools-execution",
377
+ url: "ws://localhost:3000/agents/chat/thread-tools-execution?_pk=abc"
378
+ });
379
+
380
+ const mockExecute = vi.fn().mockResolvedValue({ success: true });
381
+
382
+ // Single unified tools object - schema + execute in one place
383
+ const tools: Record<string, AITool> = {
384
+ showAlert: {
385
+ description: "Shows an alert",
386
+ parameters: {
387
+ type: "object",
388
+ properties: { message: { type: "string" } }
389
+ },
390
+ execute: mockExecute
391
+ }
392
+ };
393
+
394
+ const TestComponent = () => {
395
+ const chat = useAgentChat({
396
+ agent,
397
+ getInitialMessages: null,
398
+ tools
399
+ });
400
+ return <div data-testid="messages-count">{chat.messages.length}</div>;
401
+ };
402
+
403
+ const screen = await act(() =>
404
+ render(<TestComponent />, {
405
+ wrapper: ({ children }) => (
406
+ <StrictMode>
407
+ <Suspense fallback="Loading...">{children}</Suspense>
408
+ </StrictMode>
409
+ )
410
+ })
411
+ );
412
+
413
+ // Verify component renders without errors
414
+ await expect
415
+ .element(screen.getByTestId("messages-count"))
416
+ .toHaveTextContent("0");
417
+ });
418
+ });
419
+
420
+ describe("useAgentChat client-side tool execution (issue #728)", () => {
421
+ it("should update tool part state from input-available to output-available when addToolResult is called", async () => {
422
+ const agent = createAgent({
423
+ name: "tool-state-test",
424
+ url: "ws://localhost:3000/agents/chat/tool-state-test?_pk=abc"
425
+ });
426
+
427
+ const mockExecute = vi.fn().mockResolvedValue({ location: "New York" });
428
+
429
+ // Initial messages with a tool call in input-available state
430
+ const initialMessages: UIMessage[] = [
431
+ {
432
+ id: "msg-1",
433
+ role: "user",
434
+ parts: [{ type: "text", text: "Where am I?" }]
435
+ },
436
+ {
437
+ id: "msg-2",
438
+ role: "assistant",
439
+ parts: [
440
+ {
441
+ type: "tool-getLocation",
442
+ toolCallId: "tool-call-1",
443
+ state: "input-available",
444
+ input: {}
445
+ }
446
+ ]
447
+ }
448
+ ];
449
+
450
+ const TestComponent = () => {
451
+ const chat = useAgentChat({
452
+ agent,
453
+ getInitialMessages: () => Promise.resolve(initialMessages),
454
+ experimental_automaticToolResolution: true,
455
+ tools: {
456
+ getLocation: {
457
+ execute: mockExecute
458
+ }
459
+ }
460
+ });
461
+
462
+ // Find the tool part to check its state
463
+ const assistantMsg = chat.messages.find((m) => m.role === "assistant");
464
+ const toolPart = assistantMsg?.parts.find(
465
+ (p) => "toolCallId" in p && p.toolCallId === "tool-call-1"
466
+ );
467
+ const toolState =
468
+ toolPart && "state" in toolPart ? toolPart.state : "not-found";
469
+
470
+ return (
471
+ <div>
472
+ <div data-testid="messages-count">{chat.messages.length}</div>
473
+ <div data-testid="tool-state">{toolState}</div>
474
+ </div>
475
+ );
476
+ };
477
+
478
+ const screen = await act(() =>
479
+ render(<TestComponent />, {
480
+ wrapper: ({ children }) => (
481
+ <StrictMode>
482
+ <Suspense fallback="Loading...">{children}</Suspense>
483
+ </StrictMode>
484
+ )
485
+ })
486
+ );
487
+
488
+ // Wait for initial messages to load
489
+ await expect
490
+ .element(screen.getByTestId("messages-count"))
491
+ .toHaveTextContent("2");
492
+
493
+ // The tool should have been automatically executed
494
+ await act(async () => {
495
+ await new Promise((resolve) => setTimeout(resolve, 100));
496
+ });
497
+
498
+ // Verify the tool execute was called
499
+ expect(mockExecute).toHaveBeenCalled();
500
+
501
+ // the tool part should be updated to output-available
502
+ // in the SAME message (msg-2), not in a new message
503
+ await expect
504
+ .element(screen.getByTestId("messages-count"))
505
+ .toHaveTextContent("2"); // Should still be 2 messages, not 3
506
+
507
+ // The tool state should be output-available after addToolResult
508
+ await expect
509
+ .element(screen.getByTestId("tool-state"))
510
+ .toHaveTextContent("output-available");
511
+ });
512
+
513
+ it("should not create duplicate tool parts when client executes tool", async () => {
514
+ const agent = createAgent({
515
+ name: "duplicate-test",
516
+ url: "ws://localhost:3000/agents/chat/duplicate-test?_pk=abc"
517
+ });
518
+
519
+ const mockExecute = vi.fn().mockResolvedValue({ confirmed: true });
520
+
521
+ const initialMessages: UIMessage[] = [
522
+ {
523
+ id: "msg-1",
524
+ role: "assistant",
525
+ parts: [
526
+ { type: "text", text: "Should I proceed?" },
527
+ {
528
+ type: "tool-askForConfirmation",
529
+ toolCallId: "confirm-1",
530
+ state: "input-available",
531
+ input: { message: "Proceed with action?" }
532
+ }
533
+ ]
534
+ }
535
+ ];
536
+
537
+ let chatInstance: ReturnType<typeof useAgentChat> | null = null;
538
+
539
+ const TestComponent = () => {
540
+ const chat = useAgentChat({
541
+ agent,
542
+ getInitialMessages: () => Promise.resolve(initialMessages),
543
+ tools: {
544
+ askForConfirmation: {
545
+ execute: mockExecute
546
+ }
547
+ }
548
+ });
549
+ chatInstance = chat;
550
+
551
+ // Count tool parts with this toolCallId
552
+ const toolPartsCount = chat.messages.reduce((count, msg) => {
553
+ return (
554
+ count +
555
+ msg.parts.filter(
556
+ (p) => "toolCallId" in p && p.toolCallId === "confirm-1"
557
+ ).length
558
+ );
559
+ }, 0);
560
+
561
+ // Get the tool state
562
+ const toolPart = chat.messages
563
+ .flatMap((m) => m.parts)
564
+ .find((p) => "toolCallId" in p && p.toolCallId === "confirm-1");
565
+ const toolState =
566
+ toolPart && "state" in toolPart ? toolPart.state : "not-found";
567
+
568
+ return (
569
+ <div>
570
+ <div data-testid="messages-count">{chat.messages.length}</div>
571
+ <div data-testid="tool-parts-count">{toolPartsCount}</div>
572
+ <div data-testid="tool-state">{toolState}</div>
573
+ </div>
574
+ );
575
+ };
576
+
577
+ const screen = await act(() =>
578
+ render(<TestComponent />, {
579
+ wrapper: ({ children }) => (
580
+ <StrictMode>
581
+ <Suspense fallback="Loading...">{children}</Suspense>
582
+ </StrictMode>
583
+ )
584
+ })
585
+ );
586
+
587
+ await expect
588
+ .element(screen.getByTestId("messages-count"))
589
+ .toHaveTextContent("1");
590
+
591
+ // Manually trigger addToolResult to simulate user confirming
592
+ await act(async () => {
593
+ if (chatInstance) {
594
+ await chatInstance.addToolResult({
595
+ tool: "askForConfirmation",
596
+ toolCallId: "confirm-1",
597
+ output: { confirmed: true }
598
+ });
599
+ }
600
+ });
601
+
602
+ // There should still be exactly ONE tool part with this toolCallId
603
+ await expect
604
+ .element(screen.getByTestId("tool-parts-count"))
605
+ .toHaveTextContent("1");
606
+
607
+ // The tool state should be updated to output-available
608
+ await expect
609
+ .element(screen.getByTestId("tool-state"))
610
+ .toHaveTextContent("output-available");
611
+ });
612
+ });
@@ -0,0 +1,17 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ browser: {
6
+ enabled: true,
7
+ instances: [
8
+ {
9
+ browser: "chromium",
10
+ headless: true
11
+ }
12
+ ],
13
+ provider: "playwright"
14
+ },
15
+ clearMocks: true
16
+ }
17
+ });