branch:
client-tools-reconnect.test.ts
4599 bytesRaw
import { env } from "cloudflare:workers";
import { describe, it, expect } from "vitest";
import { MessageType } from "../types";
import type { UIMessage as ChatMessage } from "ai";
import { connectChatWS } from "./test-utils";
import { getAgentByName } from "agents";

describe("Client tools after reconnect", () => {
  it("should use client tools from CF_AGENT_TOOL_RESULT for continuation", async () => {
    const room = crypto.randomUUID();

    // Step 1: Set up a conversation with a pending tool call (simulates state before refresh)
    const agentStub = await getAgentByName(env.TestChatAgent, room);

    const userMessage: ChatMessage = {
      id: "msg1",
      role: "user",
      parts: [{ type: "text", text: "Change the background" }]
    };

    const toolCallId = "call_reconnect_test";
    const assistantMessage: ChatMessage = {
      id: "assistant-1",
      role: "assistant",
      parts: [
        {
          type: "tool-changeBackgroundColor",
          toolCallId,
          state: "input-available",
          input: { color: "blue" }
        }
      ] as ChatMessage["parts"]
    };

    // Persist messages directly (simulates state loaded from SQLite after DO restart)
    await agentStub.persistMessages([userMessage, assistantMessage]);

    // Step 2: Connect (simulates reconnect after refresh)
    // Note: We intentionally do NOT send a CF_AGENT_USE_CHAT_REQUEST first,
    // so _lastClientTools is never set — this simulates DO restart.
    const { ws } = await connectChatWS(`/agents/test-chat-agent/${room}`);

    // Wait for connection to be established
    await new Promise((resolve) => setTimeout(resolve, 200));

    // Clear any captured context from connection setup
    await agentStub.clearCapturedContext();

    // Step 3: Send tool result WITH clientTools (simulates client approval after reconnect)
    const clientTools = [
      {
        name: "changeBackgroundColor",
        description: "Changes the background color",
        parameters: {
          type: "object",
          properties: { color: { type: "string" } }
        }
      },
      {
        name: "changeTextColor",
        description: "Changes the text color",
        parameters: {
          type: "object",
          properties: { color: { type: "string" } }
        }
      }
    ];

    ws.send(
      JSON.stringify({
        type: MessageType.CF_AGENT_TOOL_RESULT,
        toolCallId,
        toolName: "changeBackgroundColor",
        output: { success: true },
        autoContinue: true,
        clientTools
      })
    );

    // Wait for tool result to be applied + 500ms stream wait + continuation
    await new Promise((resolve) => setTimeout(resolve, 1500));

    // Step 4: Verify continuation received client tools
    const capturedClientTools = await agentStub.getCapturedClientTools();
    expect(capturedClientTools).toBeDefined();
    expect(capturedClientTools).toHaveLength(2);
    expect(capturedClientTools![0].name).toBe("changeBackgroundColor");
    expect(capturedClientTools![1].name).toBe("changeTextColor");

    ws.close(1000);
  });

  it("should work without clientTools in CF_AGENT_TOOL_RESULT (backwards compat)", async () => {
    const room = crypto.randomUUID();

    const agentStub = await getAgentByName(env.TestChatAgent, room);

    // Persist a conversation with a pending tool call
    await agentStub.persistMessages([
      {
        id: "msg1",
        role: "user",
        parts: [{ type: "text", text: "Do something" }]
      },
      {
        id: "assistant-1",
        role: "assistant",
        parts: [
          {
            type: "tool-testTool",
            toolCallId: "call_compat_test",
            state: "input-available",
            input: {}
          }
        ] as ChatMessage["parts"]
      }
    ]);

    const { ws } = await connectChatWS(`/agents/test-chat-agent/${room}`);
    await new Promise((resolve) => setTimeout(resolve, 200));
    await agentStub.clearCapturedContext();

    // Send tool result WITHOUT clientTools (old client behavior)
    ws.send(
      JSON.stringify({
        type: MessageType.CF_AGENT_TOOL_RESULT,
        toolCallId: "call_compat_test",
        toolName: "testTool",
        output: { success: true },
        autoContinue: true
        // No clientTools field
      })
    );

    await new Promise((resolve) => setTimeout(resolve, 1500));

    // Continuation should still fire, just without client tools
    const capturedClientTools = await agentStub.getCapturedClientTools();
    expect(capturedClientTools).toBeUndefined();

    ws.close(1000);
  });
});