@cloudflare/ai-chat 0.0.1 → 0.0.4
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 +37 -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 +330 -0
- package/dist/index.js +1170 -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 +63 -9
- package/scripts/build.ts +31 -0
- package/src/ai-chat-v5-migration.ts +376 -0
- package/src/index.ts +2094 -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 +84 -0
- package/src/tests/chat-persistence.test.ts +425 -0
- package/src/tests/client-tool-duplicate-message.test.ts +543 -0
- package/src/tests/client-tools-broadcast.test.ts +138 -0
- package/src/tests/cloudflare-test.d.ts +5 -0
- package/src/tests/non-sse-response.test.ts +186 -0
- package/src/tests/resumable-streaming.test.ts +501 -0
- package/src/tests/test-utils.ts +39 -0
- package/src/tests/tsconfig.json +10 -0
- package/src/tests/vitest.config.ts +28 -0
- package/src/tests/worker.ts +258 -0
- package/src/tests/wrangler.jsonc +26 -0
- package/src/types.ts +122 -0
- package/tsconfig.json +4 -0
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
import { createExecutionContext, env } from "cloudflare:test";
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
import worker from "./worker";
|
|
4
|
+
import type { UIMessage as ChatMessage } from "ai";
|
|
5
|
+
|
|
6
|
+
describe("Client-side tool duplicate message prevention", () => {
|
|
7
|
+
it("merges tool output into existing message by toolCallId", async () => {
|
|
8
|
+
const room = crypto.randomUUID();
|
|
9
|
+
const ctx = createExecutionContext();
|
|
10
|
+
const req = new Request(
|
|
11
|
+
`http://example.com/agents/test-chat-agent/${room}`,
|
|
12
|
+
{ headers: { Upgrade: "websocket" } }
|
|
13
|
+
);
|
|
14
|
+
const res = await worker.fetch(req, env, ctx);
|
|
15
|
+
expect(res.status).toBe(101);
|
|
16
|
+
const ws = res.webSocket as WebSocket;
|
|
17
|
+
ws.accept();
|
|
18
|
+
await ctx.waitUntil(Promise.resolve());
|
|
19
|
+
|
|
20
|
+
const agentStub = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
|
|
21
|
+
const toolCallId = "call_merge_test";
|
|
22
|
+
|
|
23
|
+
// Persist assistant message with tool in input-available state
|
|
24
|
+
await agentStub.persistMessages([
|
|
25
|
+
{
|
|
26
|
+
id: "user-1",
|
|
27
|
+
role: "user",
|
|
28
|
+
parts: [{ type: "text", text: "Test" }]
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: "assistant-original",
|
|
32
|
+
role: "assistant",
|
|
33
|
+
parts: [
|
|
34
|
+
{
|
|
35
|
+
type: "tool-testTool",
|
|
36
|
+
toolCallId,
|
|
37
|
+
state: "input-available",
|
|
38
|
+
input: { param: "value" }
|
|
39
|
+
}
|
|
40
|
+
] as ChatMessage["parts"]
|
|
41
|
+
}
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
// Persist message with different ID but same toolCallId (simulates second stream)
|
|
45
|
+
await agentStub.persistMessages([
|
|
46
|
+
{
|
|
47
|
+
id: "user-1",
|
|
48
|
+
role: "user",
|
|
49
|
+
parts: [{ type: "text", text: "Test" }]
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "assistant-different-id",
|
|
53
|
+
role: "assistant",
|
|
54
|
+
parts: [
|
|
55
|
+
{
|
|
56
|
+
type: "tool-testTool",
|
|
57
|
+
toolCallId,
|
|
58
|
+
state: "output-available",
|
|
59
|
+
input: { param: "value" },
|
|
60
|
+
output: "result"
|
|
61
|
+
}
|
|
62
|
+
] as ChatMessage["parts"]
|
|
63
|
+
}
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
|
|
67
|
+
const assistantMessages = messages.filter((m) => m.role === "assistant");
|
|
68
|
+
|
|
69
|
+
// Should have exactly 1 assistant message (merged, not duplicated)
|
|
70
|
+
expect(assistantMessages.length).toBe(1);
|
|
71
|
+
const toolPart = assistantMessages[0].parts[0] as {
|
|
72
|
+
state: string;
|
|
73
|
+
output?: unknown;
|
|
74
|
+
};
|
|
75
|
+
expect(toolPart.state).toBe("output-available");
|
|
76
|
+
expect(toolPart.output).toBe("result");
|
|
77
|
+
|
|
78
|
+
ws.close();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("CF_AGENT_TOOL_RESULT applies tool result without auto-continuation by default", async () => {
|
|
82
|
+
const room = crypto.randomUUID();
|
|
83
|
+
const ctx = createExecutionContext();
|
|
84
|
+
const req = new Request(
|
|
85
|
+
`http://example.com/agents/test-chat-agent/${room}`,
|
|
86
|
+
{ headers: { Upgrade: "websocket" } }
|
|
87
|
+
);
|
|
88
|
+
const res = await worker.fetch(req, env, ctx);
|
|
89
|
+
expect(res.status).toBe(101);
|
|
90
|
+
const ws = res.webSocket as WebSocket;
|
|
91
|
+
ws.accept();
|
|
92
|
+
await ctx.waitUntil(Promise.resolve());
|
|
93
|
+
|
|
94
|
+
const agentStub = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
|
|
95
|
+
const toolCallId = "call_tool_result_test";
|
|
96
|
+
|
|
97
|
+
// Persist assistant message with tool in input-available state
|
|
98
|
+
await agentStub.persistMessages([
|
|
99
|
+
{
|
|
100
|
+
id: "user-1",
|
|
101
|
+
role: "user",
|
|
102
|
+
parts: [{ type: "text", text: "Execute tool" }]
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: "assistant-1",
|
|
106
|
+
role: "assistant",
|
|
107
|
+
parts: [
|
|
108
|
+
{
|
|
109
|
+
type: "tool-testTool",
|
|
110
|
+
toolCallId,
|
|
111
|
+
state: "input-available",
|
|
112
|
+
input: { param: "value" }
|
|
113
|
+
}
|
|
114
|
+
] as ChatMessage["parts"]
|
|
115
|
+
}
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
// Send CF_AGENT_TOOL_RESULT via WebSocket WITHOUT autoContinue flag
|
|
119
|
+
ws.send(
|
|
120
|
+
JSON.stringify({
|
|
121
|
+
type: "cf_agent_tool_result",
|
|
122
|
+
toolCallId,
|
|
123
|
+
toolName: "testTool",
|
|
124
|
+
output: { success: true }
|
|
125
|
+
// autoContinue not set - should NOT auto-continue
|
|
126
|
+
})
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
130
|
+
|
|
131
|
+
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
|
|
132
|
+
const assistantMessages = messages.filter((m) => m.role === "assistant");
|
|
133
|
+
|
|
134
|
+
// Should have exactly 1 assistant message (no auto-continuation)
|
|
135
|
+
expect(assistantMessages.length).toBe(1);
|
|
136
|
+
|
|
137
|
+
const assistantMsg = assistantMessages[0];
|
|
138
|
+
expect(assistantMsg.id).toBe("assistant-1");
|
|
139
|
+
|
|
140
|
+
// Tool result should be applied
|
|
141
|
+
const toolPart = assistantMsg.parts[0] as {
|
|
142
|
+
state: string;
|
|
143
|
+
output?: unknown;
|
|
144
|
+
};
|
|
145
|
+
expect(toolPart.state).toBe("output-available");
|
|
146
|
+
expect(toolPart.output).toEqual({ success: true });
|
|
147
|
+
|
|
148
|
+
// No continuation parts (only the original tool part)
|
|
149
|
+
expect(assistantMsg.parts.length).toBe(1);
|
|
150
|
+
|
|
151
|
+
ws.close();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("CF_AGENT_TOOL_RESULT auto-continues and merges when autoContinue is true", async () => {
|
|
155
|
+
const room = crypto.randomUUID();
|
|
156
|
+
const ctx = createExecutionContext();
|
|
157
|
+
const req = new Request(
|
|
158
|
+
`http://example.com/agents/test-chat-agent/${room}`,
|
|
159
|
+
{ headers: { Upgrade: "websocket" } }
|
|
160
|
+
);
|
|
161
|
+
const res = await worker.fetch(req, env, ctx);
|
|
162
|
+
expect(res.status).toBe(101);
|
|
163
|
+
const ws = res.webSocket as WebSocket;
|
|
164
|
+
ws.accept();
|
|
165
|
+
await ctx.waitUntil(Promise.resolve());
|
|
166
|
+
|
|
167
|
+
const agentStub = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
|
|
168
|
+
const toolCallId = "call_tool_result_auto_continue";
|
|
169
|
+
|
|
170
|
+
// Persist assistant message with tool in input-available state
|
|
171
|
+
await agentStub.persistMessages([
|
|
172
|
+
{
|
|
173
|
+
id: "user-1",
|
|
174
|
+
role: "user",
|
|
175
|
+
parts: [{ type: "text", text: "Execute tool" }]
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: "assistant-1",
|
|
179
|
+
role: "assistant",
|
|
180
|
+
parts: [
|
|
181
|
+
{
|
|
182
|
+
type: "tool-testTool",
|
|
183
|
+
toolCallId,
|
|
184
|
+
state: "input-available",
|
|
185
|
+
input: { param: "value" }
|
|
186
|
+
}
|
|
187
|
+
] as ChatMessage["parts"]
|
|
188
|
+
}
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
// Send CF_AGENT_TOOL_RESULT with autoContinue: true
|
|
192
|
+
ws.send(
|
|
193
|
+
JSON.stringify({
|
|
194
|
+
type: "cf_agent_tool_result",
|
|
195
|
+
toolCallId,
|
|
196
|
+
toolName: "testTool",
|
|
197
|
+
output: { success: true },
|
|
198
|
+
autoContinue: true
|
|
199
|
+
})
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
// Wait for tool result to be applied and continuation to happen
|
|
203
|
+
// Note: When there's no active stream, the continuation waits 500ms before proceeding
|
|
204
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
205
|
+
|
|
206
|
+
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
|
|
207
|
+
const assistantMessages = messages.filter((m) => m.role === "assistant");
|
|
208
|
+
|
|
209
|
+
// Should still have exactly 1 assistant message (continuation merged into it)
|
|
210
|
+
expect(assistantMessages.length).toBe(1);
|
|
211
|
+
|
|
212
|
+
const assistantMsg = assistantMessages[0];
|
|
213
|
+
expect(assistantMsg.id).toBe("assistant-1");
|
|
214
|
+
|
|
215
|
+
// First part should be the tool with result applied
|
|
216
|
+
const toolPart = assistantMsg.parts[0] as {
|
|
217
|
+
state: string;
|
|
218
|
+
output?: unknown;
|
|
219
|
+
};
|
|
220
|
+
expect(toolPart.state).toBe("output-available");
|
|
221
|
+
expect(toolPart.output).toEqual({ success: true });
|
|
222
|
+
|
|
223
|
+
// Continuation parts should be appended (TestChatAgent returns text response)
|
|
224
|
+
expect(assistantMsg.parts.length).toBeGreaterThan(1);
|
|
225
|
+
|
|
226
|
+
ws.close();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("strips OpenAI itemIds from persisted messages to prevent duplicate errors", async () => {
|
|
230
|
+
const room = crypto.randomUUID();
|
|
231
|
+
const ctx = createExecutionContext();
|
|
232
|
+
const req = new Request(
|
|
233
|
+
`http://example.com/agents/test-chat-agent/${room}`,
|
|
234
|
+
{ headers: { Upgrade: "websocket" } }
|
|
235
|
+
);
|
|
236
|
+
const res = await worker.fetch(req, env, ctx);
|
|
237
|
+
expect(res.status).toBe(101);
|
|
238
|
+
const ws = res.webSocket as WebSocket;
|
|
239
|
+
ws.accept();
|
|
240
|
+
await ctx.waitUntil(Promise.resolve());
|
|
241
|
+
|
|
242
|
+
const agentStub = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
|
|
243
|
+
|
|
244
|
+
// Persist message with OpenAI itemId in providerMetadata (simulates OpenAI Responses API)
|
|
245
|
+
await agentStub.persistMessages([
|
|
246
|
+
{
|
|
247
|
+
id: "user-1",
|
|
248
|
+
role: "user",
|
|
249
|
+
parts: [{ type: "text", text: "Hello" }]
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
id: "assistant-1",
|
|
253
|
+
role: "assistant",
|
|
254
|
+
parts: [
|
|
255
|
+
{
|
|
256
|
+
type: "text",
|
|
257
|
+
text: "Hello! How can I help?",
|
|
258
|
+
providerMetadata: {
|
|
259
|
+
openai: {
|
|
260
|
+
itemId: "msg_abc123xyz" // This should be stripped
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
] as ChatMessage["parts"]
|
|
265
|
+
}
|
|
266
|
+
]);
|
|
267
|
+
|
|
268
|
+
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
|
|
269
|
+
const assistantMessage = messages.find((m) => m.role === "assistant");
|
|
270
|
+
|
|
271
|
+
expect(assistantMessage).toBeDefined();
|
|
272
|
+
const textPart = assistantMessage!.parts[0] as {
|
|
273
|
+
type: string;
|
|
274
|
+
text: string;
|
|
275
|
+
providerMetadata?: {
|
|
276
|
+
openai?: {
|
|
277
|
+
itemId?: string;
|
|
278
|
+
};
|
|
279
|
+
};
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// The itemId should have been stripped during persistence
|
|
283
|
+
expect(textPart.text).toBe("Hello! How can I help?");
|
|
284
|
+
expect(textPart.providerMetadata?.openai?.itemId).toBeUndefined();
|
|
285
|
+
|
|
286
|
+
ws.close();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("strips OpenAI itemIds from tool parts with callProviderMetadata", async () => {
|
|
290
|
+
const room = crypto.randomUUID();
|
|
291
|
+
const ctx = createExecutionContext();
|
|
292
|
+
const req = new Request(
|
|
293
|
+
`http://example.com/agents/test-chat-agent/${room}`,
|
|
294
|
+
{ headers: { Upgrade: "websocket" } }
|
|
295
|
+
);
|
|
296
|
+
const res = await worker.fetch(req, env, ctx);
|
|
297
|
+
expect(res.status).toBe(101);
|
|
298
|
+
const ws = res.webSocket as WebSocket;
|
|
299
|
+
ws.accept();
|
|
300
|
+
await ctx.waitUntil(Promise.resolve());
|
|
301
|
+
|
|
302
|
+
const agentStub = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
|
|
303
|
+
const toolCallId = "call_openai_strip_test";
|
|
304
|
+
|
|
305
|
+
// Persist message with tool that has OpenAI itemId in callProviderMetadata
|
|
306
|
+
await agentStub.persistMessages([
|
|
307
|
+
{
|
|
308
|
+
id: "user-1",
|
|
309
|
+
role: "user",
|
|
310
|
+
parts: [{ type: "text", text: "What time is it?" }]
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
id: "assistant-1",
|
|
314
|
+
role: "assistant",
|
|
315
|
+
parts: [
|
|
316
|
+
{
|
|
317
|
+
type: "tool-getTime",
|
|
318
|
+
toolCallId,
|
|
319
|
+
state: "input-available",
|
|
320
|
+
input: { timezone: "UTC" },
|
|
321
|
+
callProviderMetadata: {
|
|
322
|
+
openai: {
|
|
323
|
+
itemId: "fc_xyz789" // This should be stripped
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
] as ChatMessage["parts"]
|
|
328
|
+
}
|
|
329
|
+
]);
|
|
330
|
+
|
|
331
|
+
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
|
|
332
|
+
const assistantMessage = messages.find((m) => m.role === "assistant");
|
|
333
|
+
|
|
334
|
+
expect(assistantMessage).toBeDefined();
|
|
335
|
+
const toolPart = assistantMessage!.parts[0] as {
|
|
336
|
+
type: string;
|
|
337
|
+
toolCallId: string;
|
|
338
|
+
callProviderMetadata?: {
|
|
339
|
+
openai?: {
|
|
340
|
+
itemId?: string;
|
|
341
|
+
};
|
|
342
|
+
};
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
// The itemId should have been stripped during persistence
|
|
346
|
+
expect(toolPart.toolCallId).toBe(toolCallId);
|
|
347
|
+
expect(toolPart.callProviderMetadata?.openai?.itemId).toBeUndefined();
|
|
348
|
+
|
|
349
|
+
ws.close();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("preserves other providerMetadata when stripping itemId", async () => {
|
|
353
|
+
const room = crypto.randomUUID();
|
|
354
|
+
const ctx = createExecutionContext();
|
|
355
|
+
const req = new Request(
|
|
356
|
+
`http://example.com/agents/test-chat-agent/${room}`,
|
|
357
|
+
{ headers: { Upgrade: "websocket" } }
|
|
358
|
+
);
|
|
359
|
+
const res = await worker.fetch(req, env, ctx);
|
|
360
|
+
expect(res.status).toBe(101);
|
|
361
|
+
const ws = res.webSocket as WebSocket;
|
|
362
|
+
ws.accept();
|
|
363
|
+
await ctx.waitUntil(Promise.resolve());
|
|
364
|
+
|
|
365
|
+
const agentStub = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
|
|
366
|
+
|
|
367
|
+
// Persist message with other metadata alongside itemId
|
|
368
|
+
await agentStub.persistMessages([
|
|
369
|
+
{
|
|
370
|
+
id: "user-1",
|
|
371
|
+
role: "user",
|
|
372
|
+
parts: [{ type: "text", text: "Hello" }]
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
id: "assistant-1",
|
|
376
|
+
role: "assistant",
|
|
377
|
+
parts: [
|
|
378
|
+
{
|
|
379
|
+
type: "text",
|
|
380
|
+
text: "Hello!",
|
|
381
|
+
providerMetadata: {
|
|
382
|
+
openai: {
|
|
383
|
+
itemId: "msg_strip_me", // Should be stripped
|
|
384
|
+
someOtherField: "keep_me" // Should be preserved
|
|
385
|
+
},
|
|
386
|
+
anthropic: {
|
|
387
|
+
someField: "also_keep_me" // Should be preserved
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
] as ChatMessage["parts"]
|
|
392
|
+
}
|
|
393
|
+
]);
|
|
394
|
+
|
|
395
|
+
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
|
|
396
|
+
const assistantMessage = messages.find((m) => m.role === "assistant");
|
|
397
|
+
|
|
398
|
+
expect(assistantMessage).toBeDefined();
|
|
399
|
+
const textPart = assistantMessage!.parts[0] as {
|
|
400
|
+
type: string;
|
|
401
|
+
providerMetadata?: {
|
|
402
|
+
openai?: {
|
|
403
|
+
itemId?: string;
|
|
404
|
+
someOtherField?: string;
|
|
405
|
+
};
|
|
406
|
+
anthropic?: {
|
|
407
|
+
someField?: string;
|
|
408
|
+
};
|
|
409
|
+
};
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
// itemId should be stripped
|
|
413
|
+
expect(textPart.providerMetadata?.openai?.itemId).toBeUndefined();
|
|
414
|
+
// Other fields should be preserved
|
|
415
|
+
expect(textPart.providerMetadata?.openai?.someOtherField).toBe("keep_me");
|
|
416
|
+
expect(textPart.providerMetadata?.anthropic?.someField).toBe(
|
|
417
|
+
"also_keep_me"
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
ws.close();
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("filters out empty reasoning parts to prevent AI SDK warnings", async () => {
|
|
424
|
+
const room = crypto.randomUUID();
|
|
425
|
+
const ctx = createExecutionContext();
|
|
426
|
+
const req = new Request(
|
|
427
|
+
`http://example.com/agents/test-chat-agent/${room}`,
|
|
428
|
+
{ headers: { Upgrade: "websocket" } }
|
|
429
|
+
);
|
|
430
|
+
const res = await worker.fetch(req, env, ctx);
|
|
431
|
+
expect(res.status).toBe(101);
|
|
432
|
+
const ws = res.webSocket as WebSocket;
|
|
433
|
+
ws.accept();
|
|
434
|
+
await ctx.waitUntil(Promise.resolve());
|
|
435
|
+
|
|
436
|
+
const agentStub = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
|
|
437
|
+
|
|
438
|
+
// Persist message with empty reasoning part (simulates OpenAI Responses API)
|
|
439
|
+
await agentStub.persistMessages([
|
|
440
|
+
{
|
|
441
|
+
id: "user-1",
|
|
442
|
+
role: "user",
|
|
443
|
+
parts: [{ type: "text", text: "Think about this" }]
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
id: "assistant-1",
|
|
447
|
+
role: "assistant",
|
|
448
|
+
parts: [
|
|
449
|
+
{
|
|
450
|
+
type: "reasoning",
|
|
451
|
+
text: "", // Empty reasoning - should be filtered out
|
|
452
|
+
providerMetadata: {
|
|
453
|
+
openai: {
|
|
454
|
+
reasoningEncryptedContent: null
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
type: "text",
|
|
460
|
+
text: "Here is my response"
|
|
461
|
+
}
|
|
462
|
+
] as ChatMessage["parts"]
|
|
463
|
+
}
|
|
464
|
+
]);
|
|
465
|
+
|
|
466
|
+
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
|
|
467
|
+
const assistantMessage = messages.find((m) => m.role === "assistant");
|
|
468
|
+
|
|
469
|
+
expect(assistantMessage).toBeDefined();
|
|
470
|
+
// Empty reasoning part should have been filtered out
|
|
471
|
+
expect(assistantMessage!.parts.length).toBe(1);
|
|
472
|
+
expect(assistantMessage!.parts[0].type).toBe("text");
|
|
473
|
+
|
|
474
|
+
ws.close();
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it("preserves non-empty reasoning parts", async () => {
|
|
478
|
+
const room = crypto.randomUUID();
|
|
479
|
+
const ctx = createExecutionContext();
|
|
480
|
+
const req = new Request(
|
|
481
|
+
`http://example.com/agents/test-chat-agent/${room}`,
|
|
482
|
+
{ headers: { Upgrade: "websocket" } }
|
|
483
|
+
);
|
|
484
|
+
const res = await worker.fetch(req, env, ctx);
|
|
485
|
+
expect(res.status).toBe(101);
|
|
486
|
+
const ws = res.webSocket as WebSocket;
|
|
487
|
+
ws.accept();
|
|
488
|
+
await ctx.waitUntil(Promise.resolve());
|
|
489
|
+
|
|
490
|
+
const agentStub = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
|
|
491
|
+
|
|
492
|
+
// Persist message with non-empty reasoning part
|
|
493
|
+
await agentStub.persistMessages([
|
|
494
|
+
{
|
|
495
|
+
id: "user-1",
|
|
496
|
+
role: "user",
|
|
497
|
+
parts: [{ type: "text", text: "Think about this" }]
|
|
498
|
+
},
|
|
499
|
+
{
|
|
500
|
+
id: "assistant-1",
|
|
501
|
+
role: "assistant",
|
|
502
|
+
parts: [
|
|
503
|
+
{
|
|
504
|
+
type: "reasoning",
|
|
505
|
+
text: "Let me think about this carefully...", // Non-empty - should be kept
|
|
506
|
+
providerMetadata: {
|
|
507
|
+
openai: {
|
|
508
|
+
itemId: "reason_123" // But itemId should still be stripped
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
type: "text",
|
|
514
|
+
text: "Here is my response"
|
|
515
|
+
}
|
|
516
|
+
] as ChatMessage["parts"]
|
|
517
|
+
}
|
|
518
|
+
]);
|
|
519
|
+
|
|
520
|
+
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
|
|
521
|
+
const assistantMessage = messages.find((m) => m.role === "assistant");
|
|
522
|
+
|
|
523
|
+
expect(assistantMessage).toBeDefined();
|
|
524
|
+
// Non-empty reasoning part should be preserved
|
|
525
|
+
expect(assistantMessage!.parts.length).toBe(2);
|
|
526
|
+
expect(assistantMessage!.parts[0].type).toBe("reasoning");
|
|
527
|
+
|
|
528
|
+
const reasoningPart = assistantMessage!.parts[0] as {
|
|
529
|
+
type: string;
|
|
530
|
+
text: string;
|
|
531
|
+
providerMetadata?: {
|
|
532
|
+
openai?: {
|
|
533
|
+
itemId?: string;
|
|
534
|
+
};
|
|
535
|
+
};
|
|
536
|
+
};
|
|
537
|
+
expect(reasoningPart.text).toBe("Let me think about this carefully...");
|
|
538
|
+
// itemId should still be stripped
|
|
539
|
+
expect(reasoningPart.providerMetadata?.openai?.itemId).toBeUndefined();
|
|
540
|
+
|
|
541
|
+
ws.close();
|
|
542
|
+
});
|
|
543
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { MessageType } from "../types";
|
|
3
|
+
import type { UIMessage as ChatMessage } from "ai";
|
|
4
|
+
import { connectChatWS } from "./test-utils";
|
|
5
|
+
|
|
6
|
+
describe("Client Tools Broadcast", () => {
|
|
7
|
+
it("should not broadcast CF_AGENT_CHAT_MESSAGES back to the originating connection after chat request", async () => {
|
|
8
|
+
const room = crypto.randomUUID();
|
|
9
|
+
const { ws } = await connectChatWS(`/agents/test-chat-agent/${room}`);
|
|
10
|
+
|
|
11
|
+
const receivedMessages: Array<{ type: string; [key: string]: unknown }> =
|
|
12
|
+
[];
|
|
13
|
+
let resolvePromise: (value: boolean) => void;
|
|
14
|
+
const donePromise = new Promise<boolean>((res) => {
|
|
15
|
+
resolvePromise = res;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const timeout = setTimeout(() => resolvePromise(false), 2000);
|
|
19
|
+
|
|
20
|
+
ws.addEventListener("message", (e: MessageEvent) => {
|
|
21
|
+
const data = JSON.parse(e.data as string);
|
|
22
|
+
receivedMessages.push(data);
|
|
23
|
+
|
|
24
|
+
// Wait for the response to complete
|
|
25
|
+
if (data.type === MessageType.CF_AGENT_USE_CHAT_RESPONSE && data.done) {
|
|
26
|
+
// Give a small delay to catch any broadcast that might follow
|
|
27
|
+
setTimeout(() => {
|
|
28
|
+
clearTimeout(timeout);
|
|
29
|
+
resolvePromise(true);
|
|
30
|
+
}, 100);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const userMessage: ChatMessage = {
|
|
35
|
+
id: "msg1",
|
|
36
|
+
role: "user",
|
|
37
|
+
parts: [{ type: "text", text: "Hello" }]
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Send chat request from the client
|
|
41
|
+
ws.send(
|
|
42
|
+
JSON.stringify({
|
|
43
|
+
type: MessageType.CF_AGENT_USE_CHAT_REQUEST,
|
|
44
|
+
id: "req1",
|
|
45
|
+
init: {
|
|
46
|
+
method: "POST",
|
|
47
|
+
body: JSON.stringify({ messages: [userMessage] })
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const done = await donePromise;
|
|
53
|
+
expect(done).toBe(true);
|
|
54
|
+
|
|
55
|
+
// The originating connection should NOT receive CF_AGENT_CHAT_MESSAGES
|
|
56
|
+
// It should only receive CF_AGENT_USE_CHAT_RESPONSE messages
|
|
57
|
+
const chatMessagesReceived = receivedMessages.filter(
|
|
58
|
+
(m) => m.type === MessageType.CF_AGENT_CHAT_MESSAGES
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// This is the bug: the originating connection receives CF_AGENT_CHAT_MESSAGES
|
|
62
|
+
// which causes duplicate messages when combined with the stream response
|
|
63
|
+
expect(chatMessagesReceived.length).toBe(0);
|
|
64
|
+
|
|
65
|
+
ws.close();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("should broadcast CF_AGENT_CHAT_MESSAGES to other connections but not the originator", async () => {
|
|
69
|
+
const room = crypto.randomUUID();
|
|
70
|
+
|
|
71
|
+
// Connect two clients to the same room
|
|
72
|
+
const { ws: ws1 } = await connectChatWS(`/agents/test-chat-agent/${room}`);
|
|
73
|
+
const { ws: ws2 } = await connectChatWS(`/agents/test-chat-agent/${room}`);
|
|
74
|
+
|
|
75
|
+
const ws1Messages: Array<{ type: string; [key: string]: unknown }> = [];
|
|
76
|
+
const ws2Messages: Array<{ type: string; [key: string]: unknown }> = [];
|
|
77
|
+
|
|
78
|
+
let resolvePromise: (value: boolean) => void;
|
|
79
|
+
const donePromise = new Promise<boolean>((res) => {
|
|
80
|
+
resolvePromise = res;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const timeout = setTimeout(() => resolvePromise(false), 2000);
|
|
84
|
+
|
|
85
|
+
ws1.addEventListener("message", (e: MessageEvent) => {
|
|
86
|
+
const data = JSON.parse(e.data as string);
|
|
87
|
+
ws1Messages.push(data);
|
|
88
|
+
|
|
89
|
+
if (data.type === MessageType.CF_AGENT_USE_CHAT_RESPONSE && data.done) {
|
|
90
|
+
setTimeout(() => {
|
|
91
|
+
clearTimeout(timeout);
|
|
92
|
+
resolvePromise(true);
|
|
93
|
+
}, 100);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
ws2.addEventListener("message", (e: MessageEvent) => {
|
|
98
|
+
const data = JSON.parse(e.data as string);
|
|
99
|
+
ws2Messages.push(data);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const userMessage: ChatMessage = {
|
|
103
|
+
id: "msg1",
|
|
104
|
+
role: "user",
|
|
105
|
+
parts: [{ type: "text", text: "Hello" }]
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// WS1 sends the chat request
|
|
109
|
+
ws1.send(
|
|
110
|
+
JSON.stringify({
|
|
111
|
+
type: MessageType.CF_AGENT_USE_CHAT_REQUEST,
|
|
112
|
+
id: "req1",
|
|
113
|
+
init: {
|
|
114
|
+
method: "POST",
|
|
115
|
+
body: JSON.stringify({ messages: [userMessage] })
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const done = await donePromise;
|
|
121
|
+
expect(done).toBe(true);
|
|
122
|
+
|
|
123
|
+
// WS1 (originator) should NOT receive CF_AGENT_CHAT_MESSAGES
|
|
124
|
+
const ws1ChatMessages = ws1Messages.filter(
|
|
125
|
+
(m) => m.type === MessageType.CF_AGENT_CHAT_MESSAGES
|
|
126
|
+
);
|
|
127
|
+
expect(ws1ChatMessages.length).toBe(0);
|
|
128
|
+
|
|
129
|
+
// WS2 (other connection) SHOULD receive CF_AGENT_CHAT_MESSAGES
|
|
130
|
+
const ws2ChatMessages = ws2Messages.filter(
|
|
131
|
+
(m) => m.type === MessageType.CF_AGENT_CHAT_MESSAGES
|
|
132
|
+
);
|
|
133
|
+
expect(ws2ChatMessages.length).toBeGreaterThan(0);
|
|
134
|
+
|
|
135
|
+
ws1.close();
|
|
136
|
+
ws2.close();
|
|
137
|
+
});
|
|
138
|
+
});
|