@cloudflare/ai-chat 0.0.4 → 0.0.6
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 +28 -0
- package/dist/ai-chat-v5-migration.d.ts +0 -1
- package/dist/ai-chat-v5-migration.js.map +1 -1
- package/dist/index.d.ts +25 -8
- package/dist/index.js +465 -354
- package/dist/index.js.map +1 -1
- package/dist/react.d.ts +27 -48
- package/dist/react.js +40 -18
- package/dist/react.js.map +1 -1
- package/dist/types.d.ts +27 -48
- package/dist/types.js +13 -11
- package/dist/types.js.map +1 -1
- package/package.json +6 -6
- package/src/index.ts +865 -626
- package/src/react-tests/setup.ts +3 -0
- package/src/react-tests/use-agent-chat.test.tsx +35 -23
- package/src/react-tests/vitest.config.ts +2 -1
- package/src/react.tsx +59 -0
- package/src/tests/chat-context.test.ts +2 -1
- package/src/tests/chat-persistence.test.ts +4 -3
- package/src/tests/client-tool-duplicate-message.test.ts +322 -8
- package/src/tests/resumable-streaming.test.ts +90 -39
- package/src/tests/worker.ts +24 -0
- package/src/types.ts +11 -1
|
@@ -10,6 +10,10 @@ import {
|
|
|
10
10
|
} from "../react";
|
|
11
11
|
import type { useAgent } from "agents/react";
|
|
12
12
|
|
|
13
|
+
function sleep(ms: number) {
|
|
14
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
15
|
+
}
|
|
16
|
+
|
|
13
17
|
function createAgent({ name, url }: { name: string; url: string }) {
|
|
14
18
|
const target = new EventTarget();
|
|
15
19
|
const baseAgent = {
|
|
@@ -63,15 +67,17 @@ describe("useAgentChat", () => {
|
|
|
63
67
|
return "Suspended";
|
|
64
68
|
};
|
|
65
69
|
|
|
66
|
-
const screen = await act(() =>
|
|
67
|
-
render(<TestComponent />, {
|
|
70
|
+
const screen = await act(async () => {
|
|
71
|
+
const screen = render(<TestComponent />, {
|
|
68
72
|
wrapper: ({ children }) => (
|
|
69
73
|
<StrictMode>
|
|
70
74
|
<Suspense fallback={<SuspenseObserver />}>{children}</Suspense>
|
|
71
75
|
</StrictMode>
|
|
72
76
|
)
|
|
73
|
-
})
|
|
74
|
-
|
|
77
|
+
});
|
|
78
|
+
await sleep(10);
|
|
79
|
+
return screen;
|
|
80
|
+
});
|
|
75
81
|
|
|
76
82
|
await expect
|
|
77
83
|
.element(screen.getByTestId("messages"))
|
|
@@ -123,15 +129,18 @@ describe("useAgentChat", () => {
|
|
|
123
129
|
return "Suspended";
|
|
124
130
|
};
|
|
125
131
|
|
|
126
|
-
const screen = await act(() =>
|
|
127
|
-
render(<TestComponent agent={agentA} />, {
|
|
132
|
+
const screen = await act(async () => {
|
|
133
|
+
const screen = render(<TestComponent agent={agentA} />, {
|
|
128
134
|
wrapper: ({ children }) => (
|
|
129
135
|
<StrictMode>
|
|
130
136
|
<Suspense fallback={<SuspenseObserver />}>{children}</Suspense>
|
|
131
137
|
</StrictMode>
|
|
132
138
|
)
|
|
133
|
-
})
|
|
134
|
-
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await sleep(10);
|
|
142
|
+
return screen;
|
|
143
|
+
});
|
|
135
144
|
|
|
136
145
|
await expect
|
|
137
146
|
.element(screen.getByTestId("messages"))
|
|
@@ -145,7 +154,10 @@ describe("useAgentChat", () => {
|
|
|
145
154
|
|
|
146
155
|
suspenseRendered.mockClear();
|
|
147
156
|
|
|
148
|
-
await act(() =>
|
|
157
|
+
await act(async () => {
|
|
158
|
+
screen.rerender(<TestComponent agent={agentB} />);
|
|
159
|
+
await sleep(10);
|
|
160
|
+
});
|
|
149
161
|
|
|
150
162
|
await expect
|
|
151
163
|
.element(screen.getByTestId("messages"))
|
|
@@ -219,7 +231,7 @@ describe("useAgentChat", () => {
|
|
|
219
231
|
_options: PrepareSendMessagesRequestOptions<UIMessage>
|
|
220
232
|
): Promise<PrepareSendMessagesRequestResult> => {
|
|
221
233
|
// Simulate async operation like fetching tool definitions
|
|
222
|
-
await
|
|
234
|
+
await sleep(10);
|
|
223
235
|
return {
|
|
224
236
|
body: {
|
|
225
237
|
clientTools: [
|
|
@@ -475,26 +487,24 @@ describe("useAgentChat client-side tool execution (issue #728)", () => {
|
|
|
475
487
|
);
|
|
476
488
|
};
|
|
477
489
|
|
|
478
|
-
const screen = await act(() =>
|
|
479
|
-
render(<TestComponent />, {
|
|
490
|
+
const screen = await act(async () => {
|
|
491
|
+
const screen = render(<TestComponent />, {
|
|
480
492
|
wrapper: ({ children }) => (
|
|
481
493
|
<StrictMode>
|
|
482
494
|
<Suspense fallback="Loading...">{children}</Suspense>
|
|
483
495
|
</StrictMode>
|
|
484
496
|
)
|
|
485
|
-
})
|
|
486
|
-
|
|
497
|
+
});
|
|
498
|
+
// The tool should have been automatically executed
|
|
499
|
+
await sleep(10);
|
|
500
|
+
return screen;
|
|
501
|
+
});
|
|
487
502
|
|
|
488
503
|
// Wait for initial messages to load
|
|
489
504
|
await expect
|
|
490
505
|
.element(screen.getByTestId("messages-count"))
|
|
491
506
|
.toHaveTextContent("2");
|
|
492
507
|
|
|
493
|
-
// The tool should have been automatically executed
|
|
494
|
-
await act(async () => {
|
|
495
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
496
|
-
});
|
|
497
|
-
|
|
498
508
|
// Verify the tool execute was called
|
|
499
509
|
expect(mockExecute).toHaveBeenCalled();
|
|
500
510
|
|
|
@@ -574,15 +584,17 @@ describe("useAgentChat client-side tool execution (issue #728)", () => {
|
|
|
574
584
|
);
|
|
575
585
|
};
|
|
576
586
|
|
|
577
|
-
const screen = await act(() =>
|
|
578
|
-
render(<TestComponent />, {
|
|
587
|
+
const screen = await act(async () => {
|
|
588
|
+
const screen = render(<TestComponent />, {
|
|
579
589
|
wrapper: ({ children }) => (
|
|
580
590
|
<StrictMode>
|
|
581
591
|
<Suspense fallback="Loading...">{children}</Suspense>
|
|
582
592
|
</StrictMode>
|
|
583
593
|
)
|
|
584
|
-
})
|
|
585
|
-
|
|
594
|
+
});
|
|
595
|
+
await sleep(10);
|
|
596
|
+
return screen;
|
|
597
|
+
});
|
|
586
598
|
|
|
587
599
|
await expect
|
|
588
600
|
.element(screen.getByTestId("messages-count"))
|
package/src/react.tsx
CHANGED
|
@@ -835,6 +835,20 @@ export function useAgentChat<
|
|
|
835
835
|
[autoContinueAfterToolResult]
|
|
836
836
|
);
|
|
837
837
|
|
|
838
|
+
// Helper function to send tool approval to server
|
|
839
|
+
const sendToolApprovalToServer = useCallback(
|
|
840
|
+
(toolCallId: string, approved: boolean) => {
|
|
841
|
+
agentRef.current.send(
|
|
842
|
+
JSON.stringify({
|
|
843
|
+
type: MessageType.CF_AGENT_TOOL_APPROVAL,
|
|
844
|
+
toolCallId,
|
|
845
|
+
approved
|
|
846
|
+
})
|
|
847
|
+
);
|
|
848
|
+
},
|
|
849
|
+
[]
|
|
850
|
+
);
|
|
851
|
+
|
|
838
852
|
// Effect for new onToolCall callback pattern (v6 style)
|
|
839
853
|
// This fires when there are tool calls that need client-side handling
|
|
840
854
|
useEffect(() => {
|
|
@@ -1279,6 +1293,45 @@ export function useAgentChat<
|
|
|
1279
1293
|
// If autoContinueAfterToolResult is true, server handles continuation
|
|
1280
1294
|
};
|
|
1281
1295
|
|
|
1296
|
+
// Wrapper that sends tool approval to server before updating local state.
|
|
1297
|
+
// This prevents duplicate messages by ensuring server updates the message
|
|
1298
|
+
// in place with the existing ID, rather than relying on ID resolution
|
|
1299
|
+
// when sendMessage() is called later.
|
|
1300
|
+
const addToolApprovalResponseAndNotifyServer: typeof useChatHelpers.addToolApprovalResponse =
|
|
1301
|
+
(args) => {
|
|
1302
|
+
const { id: approvalId, approved } = args;
|
|
1303
|
+
|
|
1304
|
+
// Find the toolCallId from the approval ID
|
|
1305
|
+
// The approval ID is stored on the tool part's approval.id field
|
|
1306
|
+
let toolCallId: string | undefined;
|
|
1307
|
+
for (const msg of messagesRef.current) {
|
|
1308
|
+
for (const part of msg.parts) {
|
|
1309
|
+
if (
|
|
1310
|
+
"toolCallId" in part &&
|
|
1311
|
+
"approval" in part &&
|
|
1312
|
+
(part.approval as { id?: string })?.id === approvalId
|
|
1313
|
+
) {
|
|
1314
|
+
toolCallId = part.toolCallId as string;
|
|
1315
|
+
break;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
if (toolCallId) break;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
if (toolCallId) {
|
|
1322
|
+
// Send approval to server first (server updates message in place)
|
|
1323
|
+
sendToolApprovalToServer(toolCallId, approved);
|
|
1324
|
+
} else {
|
|
1325
|
+
console.warn(
|
|
1326
|
+
`[useAgentChat] addToolApprovalResponse: Could not find toolCallId for approval ID "${approvalId}". ` +
|
|
1327
|
+
"Server will not be notified, which may cause duplicate messages."
|
|
1328
|
+
);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// Call AI SDK's addToolApprovalResponse for local state update
|
|
1332
|
+
useChatHelpers.addToolApprovalResponse(args);
|
|
1333
|
+
};
|
|
1334
|
+
|
|
1282
1335
|
// Fix for issue #728: Merge client-side tool results with messages
|
|
1283
1336
|
// so tool parts show output-available immediately after execution
|
|
1284
1337
|
const messagesWithToolResults = useMemo(() => {
|
|
@@ -1382,6 +1435,12 @@ export function useAgentChat<
|
|
|
1382
1435
|
* @deprecated Use `addToolOutput` instead.
|
|
1383
1436
|
*/
|
|
1384
1437
|
addToolResult: addToolResultAndSendMessage,
|
|
1438
|
+
/**
|
|
1439
|
+
* Respond to a tool approval request. Use this for tools with `needsApproval`.
|
|
1440
|
+
* This wrapper notifies the server before updating local state, preventing
|
|
1441
|
+
* duplicate messages when sendMessage() is called afterward.
|
|
1442
|
+
*/
|
|
1443
|
+
addToolApprovalResponse: addToolApprovalResponseAndNotifyServer,
|
|
1385
1444
|
clearHistory: () => {
|
|
1386
1445
|
useChatHelpers.setMessages([]);
|
|
1387
1446
|
setClientToolResults(new Map());
|
|
@@ -3,6 +3,7 @@ import { describe, it, expect } from "vitest";
|
|
|
3
3
|
import { MessageType } from "../types";
|
|
4
4
|
import type { UIMessage as ChatMessage } from "ai";
|
|
5
5
|
import { connectChatWS } from "./test-utils";
|
|
6
|
+
import { getAgentByName } from "agents";
|
|
6
7
|
|
|
7
8
|
describe("AIChatAgent Connection Context - Issue #711", () => {
|
|
8
9
|
it("getCurrentAgent() should return connection in onChatMessage and nested async functions (tool execute)", async () => {
|
|
@@ -10,7 +11,7 @@ describe("AIChatAgent Connection Context - Issue #711", () => {
|
|
|
10
11
|
const { ws } = await connectChatWS(`/agents/test-chat-agent/${room}`);
|
|
11
12
|
|
|
12
13
|
// Get the agent stub to access captured context
|
|
13
|
-
const agentStub =
|
|
14
|
+
const agentStub = await getAgentByName(env.TestChatAgent, room);
|
|
14
15
|
|
|
15
16
|
// Clear any previous captured context
|
|
16
17
|
await agentStub.clearCapturedContext();
|
|
@@ -4,6 +4,7 @@ import worker from "./worker";
|
|
|
4
4
|
import { MessageType } from "../types";
|
|
5
5
|
import type { UIMessage as ChatMessage } from "ai";
|
|
6
6
|
import { connectChatWS } from "./test-utils";
|
|
7
|
+
import { getAgentByName } from "agents";
|
|
7
8
|
|
|
8
9
|
// Type helper for tool call parts - extracts ToolUIPart from ChatMessage parts
|
|
9
10
|
type TestToolCallPart = Extract<
|
|
@@ -198,7 +199,7 @@ describe("Chat Agent Persistence", () => {
|
|
|
198
199
|
|
|
199
200
|
await ctx.waitUntil(Promise.resolve());
|
|
200
201
|
|
|
201
|
-
const agentStub =
|
|
202
|
+
const agentStub = await getAgentByName(env.TestChatAgent, room);
|
|
202
203
|
|
|
203
204
|
await agentStub.testPersistToolCall("msg-tool-1", "getLocalTime");
|
|
204
205
|
|
|
@@ -256,7 +257,7 @@ describe("Chat Agent Persistence", () => {
|
|
|
256
257
|
|
|
257
258
|
await ctx.waitUntil(Promise.resolve());
|
|
258
259
|
|
|
259
|
-
const agentStub =
|
|
260
|
+
const agentStub = await getAgentByName(env.TestChatAgent, room);
|
|
260
261
|
|
|
261
262
|
const userMessage: ChatMessage = {
|
|
262
263
|
id: "user-1",
|
|
@@ -362,7 +363,7 @@ describe("Chat Agent Persistence", () => {
|
|
|
362
363
|
|
|
363
364
|
await ctx.waitUntil(Promise.resolve());
|
|
364
365
|
|
|
365
|
-
const agentStub =
|
|
366
|
+
const agentStub = await getAgentByName(env.TestChatAgent, room);
|
|
366
367
|
|
|
367
368
|
const userMessage: ChatMessage = {
|
|
368
369
|
id: "user-1",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createExecutionContext, env } from "cloudflare:test";
|
|
2
|
+
import { getAgentByName } from "agents";
|
|
2
3
|
import { describe, it, expect } from "vitest";
|
|
3
4
|
import worker from "./worker";
|
|
4
5
|
import type { UIMessage as ChatMessage } from "ai";
|
|
@@ -17,7 +18,7 @@ describe("Client-side tool duplicate message prevention", () => {
|
|
|
17
18
|
ws.accept();
|
|
18
19
|
await ctx.waitUntil(Promise.resolve());
|
|
19
20
|
|
|
20
|
-
const agentStub =
|
|
21
|
+
const agentStub = await getAgentByName(env.TestChatAgent, room);
|
|
21
22
|
const toolCallId = "call_merge_test";
|
|
22
23
|
|
|
23
24
|
// Persist assistant message with tool in input-available state
|
|
@@ -91,7 +92,7 @@ describe("Client-side tool duplicate message prevention", () => {
|
|
|
91
92
|
ws.accept();
|
|
92
93
|
await ctx.waitUntil(Promise.resolve());
|
|
93
94
|
|
|
94
|
-
const agentStub =
|
|
95
|
+
const agentStub = await getAgentByName(env.TestChatAgent, room);
|
|
95
96
|
const toolCallId = "call_tool_result_test";
|
|
96
97
|
|
|
97
98
|
// Persist assistant message with tool in input-available state
|
|
@@ -164,7 +165,7 @@ describe("Client-side tool duplicate message prevention", () => {
|
|
|
164
165
|
ws.accept();
|
|
165
166
|
await ctx.waitUntil(Promise.resolve());
|
|
166
167
|
|
|
167
|
-
const agentStub =
|
|
168
|
+
const agentStub = await getAgentByName(env.TestChatAgent, room);
|
|
168
169
|
const toolCallId = "call_tool_result_auto_continue";
|
|
169
170
|
|
|
170
171
|
// Persist assistant message with tool in input-available state
|
|
@@ -239,7 +240,7 @@ describe("Client-side tool duplicate message prevention", () => {
|
|
|
239
240
|
ws.accept();
|
|
240
241
|
await ctx.waitUntil(Promise.resolve());
|
|
241
242
|
|
|
242
|
-
const agentStub =
|
|
243
|
+
const agentStub = await getAgentByName(env.TestChatAgent, room);
|
|
243
244
|
|
|
244
245
|
// Persist message with OpenAI itemId in providerMetadata (simulates OpenAI Responses API)
|
|
245
246
|
await agentStub.persistMessages([
|
|
@@ -299,7 +300,7 @@ describe("Client-side tool duplicate message prevention", () => {
|
|
|
299
300
|
ws.accept();
|
|
300
301
|
await ctx.waitUntil(Promise.resolve());
|
|
301
302
|
|
|
302
|
-
const agentStub =
|
|
303
|
+
const agentStub = await getAgentByName(env.TestChatAgent, room);
|
|
303
304
|
const toolCallId = "call_openai_strip_test";
|
|
304
305
|
|
|
305
306
|
// Persist message with tool that has OpenAI itemId in callProviderMetadata
|
|
@@ -362,7 +363,7 @@ describe("Client-side tool duplicate message prevention", () => {
|
|
|
362
363
|
ws.accept();
|
|
363
364
|
await ctx.waitUntil(Promise.resolve());
|
|
364
365
|
|
|
365
|
-
const agentStub =
|
|
366
|
+
const agentStub = await getAgentByName(env.TestChatAgent, room);
|
|
366
367
|
|
|
367
368
|
// Persist message with other metadata alongside itemId
|
|
368
369
|
await agentStub.persistMessages([
|
|
@@ -433,7 +434,7 @@ describe("Client-side tool duplicate message prevention", () => {
|
|
|
433
434
|
ws.accept();
|
|
434
435
|
await ctx.waitUntil(Promise.resolve());
|
|
435
436
|
|
|
436
|
-
const agentStub =
|
|
437
|
+
const agentStub = await getAgentByName(env.TestChatAgent, room);
|
|
437
438
|
|
|
438
439
|
// Persist message with empty reasoning part (simulates OpenAI Responses API)
|
|
439
440
|
await agentStub.persistMessages([
|
|
@@ -487,7 +488,7 @@ describe("Client-side tool duplicate message prevention", () => {
|
|
|
487
488
|
ws.accept();
|
|
488
489
|
await ctx.waitUntil(Promise.resolve());
|
|
489
490
|
|
|
490
|
-
const agentStub =
|
|
491
|
+
const agentStub = await getAgentByName(env.TestChatAgent, room);
|
|
491
492
|
|
|
492
493
|
// Persist message with non-empty reasoning part
|
|
493
494
|
await agentStub.persistMessages([
|
|
@@ -541,3 +542,316 @@ describe("Client-side tool duplicate message prevention", () => {
|
|
|
541
542
|
ws.close();
|
|
542
543
|
});
|
|
543
544
|
});
|
|
545
|
+
|
|
546
|
+
describe("Tool approval (needsApproval) duplicate message prevention", () => {
|
|
547
|
+
it("CF_AGENT_TOOL_APPROVAL updates existing message in place", async () => {
|
|
548
|
+
const room = crypto.randomUUID();
|
|
549
|
+
const ctx = createExecutionContext();
|
|
550
|
+
const req = new Request(
|
|
551
|
+
`http://example.com/agents/test-chat-agent/${room}`,
|
|
552
|
+
{ headers: { Upgrade: "websocket" } }
|
|
553
|
+
);
|
|
554
|
+
const res = await worker.fetch(req, env, ctx);
|
|
555
|
+
expect(res.status).toBe(101);
|
|
556
|
+
const ws = res.webSocket as WebSocket;
|
|
557
|
+
ws.accept();
|
|
558
|
+
await ctx.waitUntil(Promise.resolve());
|
|
559
|
+
|
|
560
|
+
const agentStub = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
|
|
561
|
+
const toolCallId = "call_approval_test";
|
|
562
|
+
|
|
563
|
+
// Persist assistant message with tool in input-available state (needs approval)
|
|
564
|
+
await agentStub.persistMessages([
|
|
565
|
+
{
|
|
566
|
+
id: "user-1",
|
|
567
|
+
role: "user",
|
|
568
|
+
parts: [{ type: "text", text: "Execute tool" }]
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
id: "assistant-1",
|
|
572
|
+
role: "assistant",
|
|
573
|
+
parts: [
|
|
574
|
+
{
|
|
575
|
+
type: "tool-testTool",
|
|
576
|
+
toolCallId,
|
|
577
|
+
state: "input-available",
|
|
578
|
+
input: { param: "value" }
|
|
579
|
+
}
|
|
580
|
+
] as ChatMessage["parts"]
|
|
581
|
+
}
|
|
582
|
+
]);
|
|
583
|
+
|
|
584
|
+
// Send CF_AGENT_TOOL_APPROVAL via WebSocket
|
|
585
|
+
ws.send(
|
|
586
|
+
JSON.stringify({
|
|
587
|
+
type: "cf_agent_tool_approval",
|
|
588
|
+
toolCallId,
|
|
589
|
+
approved: true
|
|
590
|
+
})
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
594
|
+
|
|
595
|
+
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
|
|
596
|
+
const assistantMessages = messages.filter((m) => m.role === "assistant");
|
|
597
|
+
|
|
598
|
+
// Should have exactly 1 assistant message (updated in place, not duplicated)
|
|
599
|
+
expect(assistantMessages.length).toBe(1);
|
|
600
|
+
|
|
601
|
+
const assistantMsg = assistantMessages[0];
|
|
602
|
+
// Message ID should be preserved
|
|
603
|
+
expect(assistantMsg.id).toBe("assistant-1");
|
|
604
|
+
|
|
605
|
+
// Tool state should be updated to approval-responded
|
|
606
|
+
const toolPart = assistantMsg.parts[0] as {
|
|
607
|
+
state: string;
|
|
608
|
+
approval?: { approved: boolean };
|
|
609
|
+
};
|
|
610
|
+
expect(toolPart.state).toBe("approval-responded");
|
|
611
|
+
expect(toolPart.approval).toEqual({ approved: true });
|
|
612
|
+
|
|
613
|
+
ws.close();
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it("CF_AGENT_TOOL_APPROVAL handles rejection (approved: false)", async () => {
|
|
617
|
+
const room = crypto.randomUUID();
|
|
618
|
+
const ctx = createExecutionContext();
|
|
619
|
+
const req = new Request(
|
|
620
|
+
`http://example.com/agents/test-chat-agent/${room}`,
|
|
621
|
+
{ headers: { Upgrade: "websocket" } }
|
|
622
|
+
);
|
|
623
|
+
const res = await worker.fetch(req, env, ctx);
|
|
624
|
+
expect(res.status).toBe(101);
|
|
625
|
+
const ws = res.webSocket as WebSocket;
|
|
626
|
+
ws.accept();
|
|
627
|
+
await ctx.waitUntil(Promise.resolve());
|
|
628
|
+
|
|
629
|
+
const agentStub = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
|
|
630
|
+
const toolCallId = "call_rejection_test";
|
|
631
|
+
|
|
632
|
+
// Persist assistant message with tool in input-available state
|
|
633
|
+
await agentStub.persistMessages([
|
|
634
|
+
{
|
|
635
|
+
id: "user-1",
|
|
636
|
+
role: "user",
|
|
637
|
+
parts: [{ type: "text", text: "Execute tool" }]
|
|
638
|
+
},
|
|
639
|
+
{
|
|
640
|
+
id: "assistant-1",
|
|
641
|
+
role: "assistant",
|
|
642
|
+
parts: [
|
|
643
|
+
{
|
|
644
|
+
type: "tool-testTool",
|
|
645
|
+
toolCallId,
|
|
646
|
+
state: "input-available",
|
|
647
|
+
input: { param: "value" }
|
|
648
|
+
}
|
|
649
|
+
] as ChatMessage["parts"]
|
|
650
|
+
}
|
|
651
|
+
]);
|
|
652
|
+
|
|
653
|
+
// Send CF_AGENT_TOOL_APPROVAL with approved: false (rejection)
|
|
654
|
+
ws.send(
|
|
655
|
+
JSON.stringify({
|
|
656
|
+
type: "cf_agent_tool_approval",
|
|
657
|
+
toolCallId,
|
|
658
|
+
approved: false
|
|
659
|
+
})
|
|
660
|
+
);
|
|
661
|
+
|
|
662
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
663
|
+
|
|
664
|
+
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
|
|
665
|
+
const assistantMessages = messages.filter((m) => m.role === "assistant");
|
|
666
|
+
|
|
667
|
+
expect(assistantMessages.length).toBe(1);
|
|
668
|
+
|
|
669
|
+
const toolPart = assistantMessages[0].parts[0] as {
|
|
670
|
+
state: string;
|
|
671
|
+
approval?: { approved: boolean };
|
|
672
|
+
};
|
|
673
|
+
expect(toolPart.state).toBe("approval-responded");
|
|
674
|
+
expect(toolPart.approval).toEqual({ approved: false });
|
|
675
|
+
|
|
676
|
+
ws.close();
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it("CF_AGENT_TOOL_APPROVAL updates tool in approval-requested state", async () => {
|
|
680
|
+
const room = crypto.randomUUID();
|
|
681
|
+
const ctx = createExecutionContext();
|
|
682
|
+
const req = new Request(
|
|
683
|
+
`http://example.com/agents/test-chat-agent/${room}`,
|
|
684
|
+
{ headers: { Upgrade: "websocket" } }
|
|
685
|
+
);
|
|
686
|
+
const res = await worker.fetch(req, env, ctx);
|
|
687
|
+
expect(res.status).toBe(101);
|
|
688
|
+
const ws = res.webSocket as WebSocket;
|
|
689
|
+
ws.accept();
|
|
690
|
+
await ctx.waitUntil(Promise.resolve());
|
|
691
|
+
|
|
692
|
+
const agentStub = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
|
|
693
|
+
const toolCallId = "call_approval_requested_test";
|
|
694
|
+
|
|
695
|
+
// Persist assistant message with tool in approval-requested state
|
|
696
|
+
await agentStub.persistMessages([
|
|
697
|
+
{
|
|
698
|
+
id: "user-1",
|
|
699
|
+
role: "user",
|
|
700
|
+
parts: [{ type: "text", text: "Execute tool" }]
|
|
701
|
+
},
|
|
702
|
+
{
|
|
703
|
+
id: "assistant-1",
|
|
704
|
+
role: "assistant",
|
|
705
|
+
parts: [
|
|
706
|
+
{
|
|
707
|
+
type: "tool-testTool",
|
|
708
|
+
toolCallId,
|
|
709
|
+
state: "approval-requested",
|
|
710
|
+
input: { param: "value" },
|
|
711
|
+
approval: { id: "approval-123" }
|
|
712
|
+
}
|
|
713
|
+
] as ChatMessage["parts"]
|
|
714
|
+
}
|
|
715
|
+
]);
|
|
716
|
+
|
|
717
|
+
// Send CF_AGENT_TOOL_APPROVAL
|
|
718
|
+
ws.send(
|
|
719
|
+
JSON.stringify({
|
|
720
|
+
type: "cf_agent_tool_approval",
|
|
721
|
+
toolCallId,
|
|
722
|
+
approved: true
|
|
723
|
+
})
|
|
724
|
+
);
|
|
725
|
+
|
|
726
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
727
|
+
|
|
728
|
+
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
|
|
729
|
+
const assistantMessages = messages.filter((m) => m.role === "assistant");
|
|
730
|
+
|
|
731
|
+
expect(assistantMessages.length).toBe(1);
|
|
732
|
+
|
|
733
|
+
const toolPart = assistantMessages[0].parts[0] as {
|
|
734
|
+
state: string;
|
|
735
|
+
approval?: { approved: boolean };
|
|
736
|
+
};
|
|
737
|
+
expect(toolPart.state).toBe("approval-responded");
|
|
738
|
+
expect(toolPart.approval).toEqual({ approved: true });
|
|
739
|
+
|
|
740
|
+
ws.close();
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
it("CF_AGENT_TOOL_APPROVAL with non-existent toolCallId logs warning", async () => {
|
|
744
|
+
const room = crypto.randomUUID();
|
|
745
|
+
const ctx = createExecutionContext();
|
|
746
|
+
const req = new Request(
|
|
747
|
+
`http://example.com/agents/test-chat-agent/${room}`,
|
|
748
|
+
{ headers: { Upgrade: "websocket" } }
|
|
749
|
+
);
|
|
750
|
+
const res = await worker.fetch(req, env, ctx);
|
|
751
|
+
expect(res.status).toBe(101);
|
|
752
|
+
const ws = res.webSocket as WebSocket;
|
|
753
|
+
ws.accept();
|
|
754
|
+
await ctx.waitUntil(Promise.resolve());
|
|
755
|
+
|
|
756
|
+
const agentStub = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
|
|
757
|
+
|
|
758
|
+
// Persist a message without any tool calls
|
|
759
|
+
await agentStub.persistMessages([
|
|
760
|
+
{
|
|
761
|
+
id: "user-1",
|
|
762
|
+
role: "user",
|
|
763
|
+
parts: [{ type: "text", text: "Hello" }]
|
|
764
|
+
},
|
|
765
|
+
{
|
|
766
|
+
id: "assistant-1",
|
|
767
|
+
role: "assistant",
|
|
768
|
+
parts: [{ type: "text", text: "Hi there!" }] as ChatMessage["parts"]
|
|
769
|
+
}
|
|
770
|
+
]);
|
|
771
|
+
|
|
772
|
+
// Send CF_AGENT_TOOL_APPROVAL for non-existent tool
|
|
773
|
+
ws.send(
|
|
774
|
+
JSON.stringify({
|
|
775
|
+
type: "cf_agent_tool_approval",
|
|
776
|
+
toolCallId: "non_existent_tool_call",
|
|
777
|
+
approved: true
|
|
778
|
+
})
|
|
779
|
+
);
|
|
780
|
+
|
|
781
|
+
await new Promise((resolve) => setTimeout(resolve, 1200)); // Wait for retries (10 * 100ms + buffer)
|
|
782
|
+
|
|
783
|
+
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
|
|
784
|
+
|
|
785
|
+
// Messages should remain unchanged (no crash, graceful handling)
|
|
786
|
+
expect(messages.length).toBe(2);
|
|
787
|
+
const assistantMsg = messages.find((m) => m.role === "assistant");
|
|
788
|
+
expect(assistantMsg?.parts[0]).toEqual({ type: "text", text: "Hi there!" });
|
|
789
|
+
|
|
790
|
+
ws.close();
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
it("CF_AGENT_TOOL_APPROVAL does not update tool already in output-available state", async () => {
|
|
794
|
+
const room = crypto.randomUUID();
|
|
795
|
+
const ctx = createExecutionContext();
|
|
796
|
+
const req = new Request(
|
|
797
|
+
`http://example.com/agents/test-chat-agent/${room}`,
|
|
798
|
+
{ headers: { Upgrade: "websocket" } }
|
|
799
|
+
);
|
|
800
|
+
const res = await worker.fetch(req, env, ctx);
|
|
801
|
+
expect(res.status).toBe(101);
|
|
802
|
+
const ws = res.webSocket as WebSocket;
|
|
803
|
+
ws.accept();
|
|
804
|
+
await ctx.waitUntil(Promise.resolve());
|
|
805
|
+
|
|
806
|
+
const agentStub = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
|
|
807
|
+
const toolCallId = "call_already_completed";
|
|
808
|
+
|
|
809
|
+
// Persist assistant message with tool already in output-available state
|
|
810
|
+
await agentStub.persistMessages([
|
|
811
|
+
{
|
|
812
|
+
id: "user-1",
|
|
813
|
+
role: "user",
|
|
814
|
+
parts: [{ type: "text", text: "Execute tool" }]
|
|
815
|
+
},
|
|
816
|
+
{
|
|
817
|
+
id: "assistant-1",
|
|
818
|
+
role: "assistant",
|
|
819
|
+
parts: [
|
|
820
|
+
{
|
|
821
|
+
type: "tool-testTool",
|
|
822
|
+
toolCallId,
|
|
823
|
+
state: "output-available",
|
|
824
|
+
input: { param: "value" },
|
|
825
|
+
output: { result: "done" }
|
|
826
|
+
}
|
|
827
|
+
] as ChatMessage["parts"]
|
|
828
|
+
}
|
|
829
|
+
]);
|
|
830
|
+
|
|
831
|
+
// Send CF_AGENT_TOOL_APPROVAL for tool that's already completed
|
|
832
|
+
ws.send(
|
|
833
|
+
JSON.stringify({
|
|
834
|
+
type: "cf_agent_tool_approval",
|
|
835
|
+
toolCallId,
|
|
836
|
+
approved: true
|
|
837
|
+
})
|
|
838
|
+
);
|
|
839
|
+
|
|
840
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
841
|
+
|
|
842
|
+
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
|
|
843
|
+
const assistantMessages = messages.filter((m) => m.role === "assistant");
|
|
844
|
+
|
|
845
|
+
expect(assistantMessages.length).toBe(1);
|
|
846
|
+
|
|
847
|
+
// State should remain output-available (not changed to approval-responded)
|
|
848
|
+
const toolPart = assistantMessages[0].parts[0] as {
|
|
849
|
+
state: string;
|
|
850
|
+
output?: unknown;
|
|
851
|
+
};
|
|
852
|
+
expect(toolPart.state).toBe("output-available");
|
|
853
|
+
expect(toolPart.output).toEqual({ result: "done" });
|
|
854
|
+
|
|
855
|
+
ws.close();
|
|
856
|
+
});
|
|
857
|
+
});
|