import { test, expect } from "@playwright/test"; /** * E2E tests for client-side tool results and auto-continuation. * Uses ClientToolAgent which defines a tool without `execute`, * so the LLM calls it but the server waits for CF_AGENT_TOOL_RESULT from the client. */ const MessageType = { CF_AGENT_USE_CHAT_REQUEST: "cf_agent_use_chat_request", CF_AGENT_USE_CHAT_RESPONSE: "cf_agent_use_chat_response", CF_AGENT_TOOL_RESULT: "cf_agent_tool_result", CF_AGENT_TOOL_APPROVAL: "cf_agent_tool_approval", CF_AGENT_MESSAGE_UPDATED: "cf_agent_message_updated" } as const; type WSMessage = { type: string; [key: string]: unknown; }; function agentPath(baseURL: string, room: string) { return `${baseURL.replace("http", "ws")}/agents/client-tool-agent/${room}`; } test.describe("Client-side tool results e2e", () => { test.setTimeout(30_000); test.beforeEach(async ({ page }) => { await page.goto("about:blank"); }); test("client tool round-trip: LLM calls tool, client sends result, server broadcasts update", async ({ page, baseURL }) => { const room = crypto.randomUUID(); const wsUrl = agentPath(baseURL!, room); // This test: // 1. Sends a message that triggers the LLM to call getUserLocation // 2. Waits for tool-input-available in the stream // 3. Sends CF_AGENT_TOOL_RESULT with the "location" // 4. Verifies CF_AGENT_MESSAGE_UPDATED is received with the output const result = await page.evaluate( ({ url, MT }) => { return new Promise<{ streamMessages: WSMessage[]; updatedMessages: WSMessage[]; toolCallId: string | null; }>((resolve) => { const ws = new WebSocket(url); const streamMessages: WSMessage[] = []; const updatedMessages: WSMessage[] = []; let toolCallId: string | null = null; let sentResult = false; ws.onmessage = (e) => { try { const data = JSON.parse(e.data) as WSMessage; if (data.type === MT.CF_AGENT_USE_CHAT_RESPONSE) { streamMessages.push(data); // Look for tool-input-available in the stream body if ( !sentResult && typeof data.body === "string" && data.body.includes("tool-input-available") ) { try { const chunk = JSON.parse(data.body as string); if ( chunk.type === "tool-input-available" && chunk.toolCallId ) { toolCallId = chunk.toolCallId; // Send tool result back to server ws.send( JSON.stringify({ type: MT.CF_AGENT_TOOL_RESULT, toolCallId: chunk.toolCallId, toolName: "getUserLocation", output: { lat: 51.5074, lng: -0.1278, city: "London" }, autoContinue: false }) ); sentResult = true; } } catch { // not JSON } } // Check for done if (data.done) { // Wait a bit for MESSAGE_UPDATED to arrive setTimeout(() => { ws.close(); resolve({ streamMessages, updatedMessages, toolCallId }); }, 1000); } } else if (data.type === MT.CF_AGENT_MESSAGE_UPDATED) { updatedMessages.push(data); } } catch { // ignore } }; ws.onopen = () => { ws.send( JSON.stringify({ type: MT.CF_AGENT_USE_CHAT_REQUEST, id: "req-client-tool", init: { method: "POST", body: JSON.stringify({ messages: [ { id: "msg-ct-1", role: "user", parts: [ { type: "text", text: "What is my current location? Use the getUserLocation tool." } ] } ] }) } }) ); }; setTimeout(() => { ws.close(); resolve({ streamMessages, updatedMessages, toolCallId }); }, 20000); }); }, { url: wsUrl, MT: MessageType } ); // The LLM should have called the tool expect(result.toolCallId).toBeTruthy(); // The server now broadcasts CF_AGENT_MESSAGE_UPDATED for streaming // messages too, so clients get immediate confirmation. expect(result.updatedMessages.length).toBeGreaterThanOrEqual(1); const updateMsg = result.updatedMessages[0]; expect(updateMsg.type).toBe(MessageType.CF_AGENT_MESSAGE_UPDATED); const message = updateMsg.message as { parts: Array<{ toolCallId?: string; state?: string; output?: unknown; }>; }; const toolPart = message.parts.find( (p) => p.toolCallId === result.toolCallId ); expect(toolPart).toBeTruthy(); expect(toolPart!.state).toBe("output-available"); expect(toolPart!.output).toEqual({ lat: 51.5074, lng: -0.1278, city: "London" }); // Also verify persistence after stream completes const res = await page.request.get( `${baseURL}/agents/client-tool-agent/${room}/get-messages` ); expect(res.ok()).toBe(true); const persisted = await res.json(); const assistantMsgs = persisted.filter( (m: { role: string }) => m.role === "assistant" ); expect(assistantMsgs.length).toBeGreaterThanOrEqual(1); }); test("auto-continuation: server continues conversation after receiving tool result", async ({ page, baseURL }) => { const room = crypto.randomUUID(); const wsUrl = agentPath(baseURL!, room); const result = await page.evaluate( ({ url, MT }) => { return new Promise<{ allMessages: WSMessage[]; continuationReceived: boolean; toolCallId: string | null; }>((resolve) => { const ws = new WebSocket(url); const allMessages: WSMessage[] = []; let toolCallId: string | null = null; let sentResult = false; let doneCount = 0; let continuationReceived = false; ws.onmessage = (e) => { try { const data = JSON.parse(e.data) as WSMessage; allMessages.push(data); if (data.type === MT.CF_AGENT_USE_CHAT_RESPONSE) { // Check for continuation flag if (data.continuation === true) { continuationReceived = true; } // Look for tool-input-available if ( !sentResult && typeof data.body === "string" && data.body.includes("tool-input-available") ) { try { const chunk = JSON.parse(data.body as string); if ( chunk.type === "tool-input-available" && chunk.toolCallId ) { toolCallId = chunk.toolCallId; // Send tool result with autoContinue=true ws.send( JSON.stringify({ type: MT.CF_AGENT_TOOL_RESULT, toolCallId: chunk.toolCallId, toolName: "getUserLocation", output: { city: "Paris", lat: 48.8566, lng: 2.3522 }, autoContinue: true }) ); sentResult = true; } } catch { // not JSON } } if (data.done) { doneCount++; // With auto-continuation, we expect 2 done signals: // 1st from the original stream, 2nd from the continuation. // Wait for both, but also handle the case where continuation // arrives in a single stream. if ( doneCount >= 2 || (doneCount >= 1 && continuationReceived) ) { setTimeout(() => { ws.close(); resolve({ allMessages, continuationReceived, toolCallId }); }, 500); } } } } catch { // ignore } }; ws.onopen = () => { ws.send( JSON.stringify({ type: MT.CF_AGENT_USE_CHAT_REQUEST, id: "req-auto-cont", init: { method: "POST", body: JSON.stringify({ messages: [ { id: "msg-ac-1", role: "user", parts: [ { type: "text", text: "Where am I? Use the getUserLocation tool." } ] } ] }) } }) ); }; setTimeout(() => { ws.close(); resolve({ allMessages, continuationReceived, toolCallId }); }, 25000); }); }, { url: wsUrl, MT: MessageType } ); expect(result.toolCallId).toBeTruthy(); // With autoContinue=true, the server should have sent a continuation stream // The continuation messages have continuation: true flag expect(result.continuationReceived).toBe(true); // The continuation should include text from the LLM responding to the tool result const continuationChunks = result.allMessages.filter( (m) => m.type === MessageType.CF_AGENT_USE_CHAT_RESPONSE && m.continuation === true && typeof m.body === "string" && (m.body as string).trim() ); expect(continuationChunks.length).toBeGreaterThan(0); }); }); test.describe("Tool approval auto-continuation e2e", () => { test.setTimeout(30_000); test.beforeEach(async ({ page }) => { await page.goto("about:blank"); }); test("tool approval with autoContinue triggers continuation stream", async ({ page, baseURL }) => { const room = crypto.randomUUID(); const wsUrl = agentPath(baseURL!, room); // This test: // 1. Sends a message that triggers the LLM to call getUserLocation // 2. Waits for tool-input-available in the stream // 3. Sends CF_AGENT_TOOL_APPROVAL (instead of TOOL_RESULT) with autoContinue // 4. Verifies continuation messages are received const result = await page.evaluate( ({ url, MT }) => { return new Promise<{ allMessages: WSMessage[]; continuationReceived: boolean; toolCallId: string | null; approvalSent: boolean; }>((resolve) => { const ws = new WebSocket(url); const allMessages: WSMessage[] = []; let toolCallId: string | null = null; let sentApproval = false; let doneCount = 0; let continuationReceived = false; ws.onmessage = (e) => { try { const data = JSON.parse(e.data) as WSMessage; allMessages.push(data); if (data.type === MT.CF_AGENT_USE_CHAT_RESPONSE) { if (data.continuation === true) { continuationReceived = true; } // Look for tool-input-available if ( !sentApproval && typeof data.body === "string" && data.body.includes("tool-input-available") ) { try { const chunk = JSON.parse(data.body as string); if ( chunk.type === "tool-input-available" && chunk.toolCallId ) { toolCallId = chunk.toolCallId; // Send tool APPROVAL with autoContinue ws.send( JSON.stringify({ type: MT.CF_AGENT_TOOL_APPROVAL, toolCallId: chunk.toolCallId, approved: true, autoContinue: true }) ); sentApproval = true; } } catch { // not JSON } } if (data.done) { doneCount++; // With auto-continuation, we expect 2 done signals if ( doneCount >= 2 || (doneCount >= 1 && continuationReceived) ) { setTimeout(() => { ws.close(); resolve({ allMessages, continuationReceived, toolCallId, approvalSent: sentApproval }); }, 500); } } } } catch { // ignore } }; ws.onopen = () => { ws.send( JSON.stringify({ type: MT.CF_AGENT_USE_CHAT_REQUEST, id: "req-approval-cont", init: { method: "POST", body: JSON.stringify({ messages: [ { id: "msg-appr-1", role: "user", parts: [ { type: "text", text: "Where am I? Use the getUserLocation tool." } ] } ] }) } }) ); }; setTimeout(() => { ws.close(); resolve({ allMessages, continuationReceived, toolCallId, approvalSent: sentApproval }); }, 25000); }); }, { url: wsUrl, MT: MessageType } ); // The LLM should have called the tool expect(result.toolCallId).toBeTruthy(); expect(result.approvalSent).toBe(true); // With autoContinue=true on approval, the server should send continuation expect(result.continuationReceived).toBe(true); // The continuation should include response chunks const continuationChunks = result.allMessages.filter( (m) => m.type === MessageType.CF_AGENT_USE_CHAT_RESPONSE && m.continuation === true && typeof m.body === "string" && (m.body as string).trim() ); expect(continuationChunks.length).toBeGreaterThan(0); // Verify persistence const res = await page.request.get( `${baseURL}/agents/client-tool-agent/${room}/get-messages` ); expect(res.ok()).toBe(true); const persisted = await res.json(); const assistantMsgs = persisted.filter( (m: { role: string }) => m.role === "assistant" ); expect(assistantMsgs.length).toBeGreaterThanOrEqual(1); // The tool part should be in approval-responded state const assistantMsg = assistantMsgs[0]; const toolPart = assistantMsg.parts.find( (p: { toolCallId?: string }) => p.toolCallId === result.toolCallId ); expect(toolPart).toBeTruthy(); expect(toolPart.state).toBe("approval-responded"); expect(toolPart.approval).toEqual({ approved: true }); }); });