@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.
@@ -0,0 +1,3 @@
1
+ // properly set up the act environment for react-tests
2
+ // @ts-expect-error - react specific API
3
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
@@ -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(() => screen.rerender(<TestComponent agent={agentB} />));
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 new Promise((resolve) => setTimeout(resolve, 10));
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"))
@@ -12,6 +12,7 @@ export default defineConfig({
12
12
  ],
13
13
  provider: "playwright"
14
14
  },
15
- clearMocks: true
15
+ clearMocks: true,
16
+ setupFiles: ["./setup.ts"]
16
17
  }
17
18
  });
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 = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
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 = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
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 = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
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 = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
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 = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
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 = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
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 = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
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 = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
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 = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
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 = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
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 = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
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 = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
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
+ });