@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.
- package/CHANGELOG.md +26 -0
- package/README.md +570 -0
- package/dist/ai-chat-v5-migration.d.ts +155 -0
- package/dist/ai-chat-v5-migration.js +155 -0
- package/dist/ai-chat-v5-migration.js.map +1 -0
- package/dist/index.d.ts +321 -0
- package/dist/index.js +1149 -0
- package/dist/index.js.map +1 -0
- package/dist/react.d.ts +282 -0
- package/dist/react.js +637 -0
- package/dist/react.js.map +1 -0
- package/dist/types.d.ts +123 -0
- package/dist/types.js +24 -0
- package/dist/types.js.map +1 -0
- package/package.json +65 -8
- package/scripts/build.ts +31 -0
- package/src/ai-chat-v5-migration.ts +376 -0
- package/src/index.ts +2059 -0
- package/src/react-tests/use-agent-chat.test.tsx +612 -0
- package/src/react-tests/vitest.config.ts +17 -0
- package/src/react.tsx +1407 -0
- package/src/tests/chat-context.test.ts +101 -0
- package/src/tests/chat-persistence.test.ts +443 -0
- package/src/tests/client-tool-duplicate-message.test.ts +547 -0
- package/src/tests/client-tools-broadcast.test.ts +156 -0
- package/src/tests/resumable-streaming.test.ts +532 -0
- package/src/tests/tsconfig.json +10 -0
- package/src/tests/vitest.config.ts +28 -0
- package/src/tests/worker.ts +260 -0
- package/src/tests/wrangler.jsonc +26 -0
- package/src/types.ts +122 -0
- package/tsconfig.json +4 -0
|
@@ -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
|
+
});
|