@cloudflare/ai-chat 0.0.5 → 0.0.7

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/src/index.ts CHANGED
@@ -535,6 +535,13 @@ export class AIChatAgent<
535
535
  );
536
536
  return;
537
537
  }
538
+
539
+ // Handle client-side tool approval response
540
+ if (data.type === MessageType.CF_AGENT_TOOL_APPROVAL) {
541
+ const { toolCallId, approved } = data;
542
+ this._applyToolApproval(toolCallId, approved);
543
+ return;
544
+ }
538
545
  }
539
546
 
540
547
  // Forward unhandled messages to consumer's onMessage
@@ -869,9 +876,9 @@ export class AIChatAgent<
869
876
  * @returns Response to send to the client or undefined
870
877
  */
871
878
  async onChatMessage(
872
- // biome-ignore lint/correctness/noUnusedFunctionParameters: overridden later
879
+ // oxlint-disable-next-line eslint(no-unused-vars) -- params used by subclass overrides
873
880
  onFinish: StreamTextOnFinishCallback<ToolSet>,
874
- // biome-ignore lint/correctness/noUnusedFunctionParameters: overridden later
881
+ // oxlint-disable-next-line eslint(no-unused-vars) -- params used by subclass overrides
875
882
  options?: OnChatMessageOptions
876
883
  ): Promise<Response | undefined> {
877
884
  throw new Error(
@@ -1008,7 +1015,9 @@ export class AIChatAgent<
1008
1015
  if (
1009
1016
  "toolCallId" in part &&
1010
1017
  "state" in part &&
1011
- part.state === "output-available"
1018
+ (part.state === "output-available" ||
1019
+ part.state === "approval-responded" ||
1020
+ part.state === "approval-requested")
1012
1021
  ) {
1013
1022
  const toolCallId = part.toolCallId as string;
1014
1023
 
@@ -1970,6 +1979,144 @@ export class AIChatAgent<
1970
1979
  }
1971
1980
  }
1972
1981
 
1982
+ /**
1983
+ * Applies a tool approval response from the client, updating the persisted message.
1984
+ * This is called when the client sends CF_AGENT_TOOL_APPROVAL for tools with needsApproval.
1985
+ * Updates the tool part state from input-available/approval-requested to approval-responded.
1986
+ *
1987
+ * @param toolCallId - The tool call ID this approval is for
1988
+ * @param approved - Whether the tool execution was approved
1989
+ * @returns true if the approval was applied, false if the message was not found
1990
+ */
1991
+ private async _applyToolApproval(
1992
+ toolCallId: string,
1993
+ approved: boolean
1994
+ ): Promise<boolean> {
1995
+ // Find the message with this tool call.
1996
+ // We check two locations:
1997
+ // 1. _streamingMessage: in-memory message being actively built during AI response
1998
+ // (not yet persisted to SQLite or available in this.messages)
1999
+ // 2. this.messages: persisted messages loaded from SQLite database
2000
+ //
2001
+ // The user can approve before streaming finishes (e.g., approval UI appears
2002
+ // while AI is still generating text), so we must check _streamingMessage first.
2003
+
2004
+ let message: ChatMessage | undefined;
2005
+
2006
+ // Check streaming message first (in-memory, not yet persisted)
2007
+ if (this._streamingMessage) {
2008
+ for (const part of this._streamingMessage.parts) {
2009
+ if ("toolCallId" in part && part.toolCallId === toolCallId) {
2010
+ message = this._streamingMessage;
2011
+ break;
2012
+ }
2013
+ }
2014
+ }
2015
+
2016
+ // If not found in streaming message, check persisted messages (in SQLite).
2017
+ // Retry with backoff in case streaming completes and persists between attempts.
2018
+ if (!message) {
2019
+ for (let attempt = 0; attempt < 10; attempt++) {
2020
+ message = this._findMessageByToolCallId(toolCallId);
2021
+ if (message) break;
2022
+ await new Promise((resolve) => setTimeout(resolve, 100));
2023
+ }
2024
+ }
2025
+
2026
+ if (!message) {
2027
+ console.warn(
2028
+ `[AIChatAgent] _applyToolApproval: Could not find message with toolCallId ${toolCallId} after retries`
2029
+ );
2030
+ return false;
2031
+ }
2032
+
2033
+ // Check if this is the streaming message (not yet persisted)
2034
+ const isStreamingMessage = message === this._streamingMessage;
2035
+
2036
+ // Update the tool part with the approval
2037
+ let updated = false;
2038
+ if (isStreamingMessage) {
2039
+ // Update in place - the message will be persisted when streaming completes
2040
+ for (const part of message.parts) {
2041
+ if (
2042
+ "toolCallId" in part &&
2043
+ part.toolCallId === toolCallId &&
2044
+ "state" in part &&
2045
+ (part.state === "input-available" ||
2046
+ part.state === "approval-requested")
2047
+ ) {
2048
+ (part as { state: string; approval?: { approved: boolean } }).state =
2049
+ "approval-responded";
2050
+ (
2051
+ part as { state: string; approval?: { approved: boolean } }
2052
+ ).approval = { approved };
2053
+ updated = true;
2054
+ break;
2055
+ }
2056
+ }
2057
+ } else {
2058
+ // For persisted messages, create updated parts
2059
+ const updatedParts = message.parts.map((part) => {
2060
+ if (
2061
+ "toolCallId" in part &&
2062
+ part.toolCallId === toolCallId &&
2063
+ "state" in part &&
2064
+ (part.state === "input-available" ||
2065
+ part.state === "approval-requested")
2066
+ ) {
2067
+ updated = true;
2068
+ return {
2069
+ ...part,
2070
+ state: "approval-responded" as const,
2071
+ approval: { approved }
2072
+ };
2073
+ }
2074
+ return part;
2075
+ }) as ChatMessage["parts"];
2076
+
2077
+ if (updated) {
2078
+ // Create the updated message and strip OpenAI item IDs
2079
+ const updatedMessage: ChatMessage = this._sanitizeMessageForPersistence(
2080
+ {
2081
+ ...message,
2082
+ parts: updatedParts
2083
+ }
2084
+ );
2085
+
2086
+ // Persist the updated message
2087
+ this.sql`
2088
+ update cf_ai_chat_agent_messages
2089
+ set message = ${JSON.stringify(updatedMessage)}
2090
+ where id = ${message.id}
2091
+ `;
2092
+
2093
+ // Reload messages to update in-memory state
2094
+ const persisted = this._loadMessagesFromDb();
2095
+ this.messages = autoTransformMessages(persisted);
2096
+ }
2097
+ }
2098
+
2099
+ if (!updated) {
2100
+ console.warn(
2101
+ `[AIChatAgent] _applyToolApproval: Tool part with toolCallId ${toolCallId} not in input-available or approval-requested state`
2102
+ );
2103
+ return false;
2104
+ }
2105
+
2106
+ // Broadcast the update to all clients (only for persisted messages)
2107
+ if (!isStreamingMessage) {
2108
+ const broadcastMessage = this._findMessageByToolCallId(toolCallId);
2109
+ if (broadcastMessage) {
2110
+ this._broadcastChatMessage({
2111
+ type: MessageType.CF_AGENT_MESSAGE_UPDATED,
2112
+ message: broadcastMessage
2113
+ });
2114
+ }
2115
+ }
2116
+
2117
+ return true;
2118
+ }
2119
+
1973
2120
  private async _reply(
1974
2121
  id: string,
1975
2122
  response: Response,
@@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config";
2
2
 
3
3
  export default defineConfig({
4
4
  test: {
5
+ name: "react",
5
6
  browser: {
6
7
  enabled: true,
7
8
  instances: [
package/src/react.tsx CHANGED
@@ -354,10 +354,12 @@ export function useAgentChat<
354
354
  onToolCallRef.current = onToolCall;
355
355
 
356
356
  const agentUrl = new URL(
357
- `${// @ts-expect-error we're using a protected _url property that includes query params
358
- ((agent._url as string | null) || agent._pkurl)
359
- ?.replace("ws://", "http://")
360
- .replace("wss://", "https://")}`
357
+ `${
358
+ // @ts-expect-error we're using a protected _url property that includes query params
359
+ ((agent._url as string | null) || agent._pkurl)
360
+ ?.replace("ws://", "http://")
361
+ .replace("wss://", "https://")
362
+ }`
361
363
  );
362
364
 
363
365
  agentUrl.searchParams.delete("_pk");
@@ -835,6 +837,20 @@ export function useAgentChat<
835
837
  [autoContinueAfterToolResult]
836
838
  );
837
839
 
840
+ // Helper function to send tool approval to server
841
+ const sendToolApprovalToServer = useCallback(
842
+ (toolCallId: string, approved: boolean) => {
843
+ agentRef.current.send(
844
+ JSON.stringify({
845
+ type: MessageType.CF_AGENT_TOOL_APPROVAL,
846
+ toolCallId,
847
+ approved
848
+ })
849
+ );
850
+ },
851
+ []
852
+ );
853
+
838
854
  // Effect for new onToolCall callback pattern (v6 style)
839
855
  // This fires when there are tool calls that need client-side handling
840
856
  useEffect(() => {
@@ -1279,6 +1295,45 @@ export function useAgentChat<
1279
1295
  // If autoContinueAfterToolResult is true, server handles continuation
1280
1296
  };
1281
1297
 
1298
+ // Wrapper that sends tool approval to server before updating local state.
1299
+ // This prevents duplicate messages by ensuring server updates the message
1300
+ // in place with the existing ID, rather than relying on ID resolution
1301
+ // when sendMessage() is called later.
1302
+ const addToolApprovalResponseAndNotifyServer: typeof useChatHelpers.addToolApprovalResponse =
1303
+ (args) => {
1304
+ const { id: approvalId, approved } = args;
1305
+
1306
+ // Find the toolCallId from the approval ID
1307
+ // The approval ID is stored on the tool part's approval.id field
1308
+ let toolCallId: string | undefined;
1309
+ for (const msg of messagesRef.current) {
1310
+ for (const part of msg.parts) {
1311
+ if (
1312
+ "toolCallId" in part &&
1313
+ "approval" in part &&
1314
+ (part.approval as { id?: string })?.id === approvalId
1315
+ ) {
1316
+ toolCallId = part.toolCallId as string;
1317
+ break;
1318
+ }
1319
+ }
1320
+ if (toolCallId) break;
1321
+ }
1322
+
1323
+ if (toolCallId) {
1324
+ // Send approval to server first (server updates message in place)
1325
+ sendToolApprovalToServer(toolCallId, approved);
1326
+ } else {
1327
+ console.warn(
1328
+ `[useAgentChat] addToolApprovalResponse: Could not find toolCallId for approval ID "${approvalId}". ` +
1329
+ "Server will not be notified, which may cause duplicate messages."
1330
+ );
1331
+ }
1332
+
1333
+ // Call AI SDK's addToolApprovalResponse for local state update
1334
+ useChatHelpers.addToolApprovalResponse(args);
1335
+ };
1336
+
1282
1337
  // Fix for issue #728: Merge client-side tool results with messages
1283
1338
  // so tool parts show output-available immediately after execution
1284
1339
  const messagesWithToolResults = useMemo(() => {
@@ -1382,6 +1437,12 @@ export function useAgentChat<
1382
1437
  * @deprecated Use `addToolOutput` instead.
1383
1438
  */
1384
1439
  addToolResult: addToolResultAndSendMessage,
1440
+ /**
1441
+ * Respond to a tool approval request. Use this for tools with `needsApproval`.
1442
+ * This wrapper notifies the server before updating local state, preventing
1443
+ * duplicate messages when sendMessage() is called afterward.
1444
+ */
1445
+ addToolApprovalResponse: addToolApprovalResponseAndNotifyServer,
1385
1446
  clearHistory: () => {
1386
1447
  useChatHelpers.setMessages([]);
1387
1448
  setClientToolResults(new Map());
@@ -542,3 +542,316 @@ describe("Client-side tool duplicate message prevention", () => {
542
542
  ws.close();
543
543
  });
544
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
+ });
@@ -2,6 +2,7 @@ import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config";
2
2
 
3
3
  export default defineWorkersConfig({
4
4
  test: {
5
+ name: "workers",
5
6
  deps: {
6
7
  optimizer: {
7
8
  ssr: {
@@ -1,5 +1,5 @@
1
1
  {
2
- "compatibility_date": "2025-04-17",
2
+ "compatibility_date": "2026-01-28",
3
3
  "compatibility_flags": [
4
4
  "nodejs_compat",
5
5
  // adding these flags since the vitest runner needs them
package/src/types.ts CHANGED
@@ -18,7 +18,9 @@ export enum MessageType {
18
18
  /** Client sends tool result to server (for client-side tools) */
19
19
  CF_AGENT_TOOL_RESULT = "cf_agent_tool_result",
20
20
  /** Server notifies client that a message was updated (e.g., tool result applied) */
21
- CF_AGENT_MESSAGE_UPDATED = "cf_agent_message_updated"
21
+ CF_AGENT_MESSAGE_UPDATED = "cf_agent_message_updated",
22
+ /** Client sends tool approval response to server (for tools with needsApproval) */
23
+ CF_AGENT_TOOL_APPROVAL = "cf_agent_tool_approval"
22
24
  }
23
25
 
24
26
  /**
@@ -119,4 +121,12 @@ export type IncomingMessage<ChatMessage extends UIMessage = UIMessage> =
119
121
  output: unknown;
120
122
  /** Whether server should auto-continue the conversation after applying result */
121
123
  autoContinue?: boolean;
124
+ }
125
+ | {
126
+ /** Client sends tool approval response to server (for tools with needsApproval) */
127
+ type: MessageType.CF_AGENT_TOOL_APPROVAL;
128
+ /** The tool call ID this approval is for */
129
+ toolCallId: string;
130
+ /** Whether the tool execution was approved */
131
+ approved: boolean;
122
132
  };
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ projects: ["src/tests/vitest.config.ts", "src/react-tests/vitest.config.ts"]
6
+ }
7
+ });