@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/CHANGELOG.md +19 -0
- package/dist/index.d.ts +12 -2
- package/dist/index.js +79 -1
- package/dist/index.js.map +1 -1
- package/dist/react.d.ts +19 -19
- package/dist/react.js +22 -0
- package/dist/react.js.map +1 -1
- package/dist/types.d.ts +24 -17
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -1
- package/package.json +7 -10
- package/scripts/build.ts +2 -2
- package/src/index.ts +150 -3
- package/src/react-tests/vitest.config.ts +1 -0
- package/src/react.tsx +65 -4
- package/src/tests/client-tool-duplicate-message.test.ts +313 -0
- package/src/tests/vitest.config.ts +1 -0
- package/src/tests/wrangler.jsonc +1 -1
- package/src/types.ts +11 -1
- package/vitest.config.ts +7 -0
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
|
-
//
|
|
879
|
+
// oxlint-disable-next-line eslint(no-unused-vars) -- params used by subclass overrides
|
|
873
880
|
onFinish: StreamTextOnFinishCallback<ToolSet>,
|
|
874
|
-
//
|
|
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,
|
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
|
-
`${
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
+
});
|
package/src/tests/wrangler.jsonc
CHANGED
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
|
};
|