branch:
merge-server-state.test.ts
14685 bytesRaw
import { env } from "cloudflare:workers";
import { describe, it, expect } from "vitest";
import type { UIMessage as ChatMessage } from "ai";
import { connectChatWS } from "./test-utils";
import { getAgentByName } from "agents";

// Type helper for tool call parts
type TestToolCallPart = Extract<
  ChatMessage["parts"][number],
  { type: `tool-${string}` }
>;

describe("Merge Incoming With Server State", () => {
  it("preserves server-side tool outputs when client sends messages without them", async () => {
    const room = crypto.randomUUID();
    const { ws } = await connectChatWS(`/agents/test-chat-agent/${room}`);
    await new Promise((r) => setTimeout(r, 50));

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

    // Step 1: Persist a message with tool output on the server
    const toolResultPart: TestToolCallPart = {
      type: "tool-getWeather",
      toolCallId: "call_merge_1",
      state: "output-available",
      input: { city: "London" },
      output: "Rainy, 12°C"
    };

    const serverMessage: ChatMessage = {
      id: "assistant-merge-1",
      role: "assistant",
      parts: [toolResultPart] as ChatMessage["parts"]
    };

    await agentStub.persistMessages([serverMessage]);

    // Step 2: Client sends the same message but without the tool output
    // (client only knows about input-available state)
    const clientMessage: ChatMessage = {
      id: "assistant-merge-1",
      role: "assistant",
      parts: [
        {
          type: "tool-getWeather",
          toolCallId: "call_merge_1",
          state: "input-available",
          input: { city: "London" }
        } as unknown as ChatMessage["parts"][number]
      ]
    };

    // Send via CF_AGENT_CHAT_MESSAGES (which triggers persistMessages with merge)
    const newUserMsg: ChatMessage = {
      id: "user-merge-1",
      role: "user",
      parts: [{ type: "text", text: "Follow up question" }]
    };

    await agentStub.persistMessages([clientMessage, newUserMsg]);

    // Step 3: Verify the tool output is preserved
    const persisted = (await agentStub.getPersistedMessages()) as ChatMessage[];

    const assistantMsg = persisted.find((m) => m.id === "assistant-merge-1");
    expect(assistantMsg).toBeDefined();

    const toolPart = assistantMsg!.parts[0] as {
      state: string;
      output?: unknown;
    };
    expect(toolPart.state).toBe("output-available");
    expect(toolPart.output).toBe("Rainy, 12°C");

    ws.close(1000);
  });

  it("preserves server-side tool outputs when client sends approval-responded state", async () => {
    const room = crypto.randomUUID();
    const { ws } = await connectChatWS(`/agents/test-chat-agent/${room}`);
    await new Promise((r) => setTimeout(r, 50));

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

    // Step 1: Server has a tool that was approved and executed (output-available)
    const toolResultPart: TestToolCallPart = {
      type: "tool-getWeather",
      toolCallId: "call_approval_merge_1",
      state: "output-available",
      input: { city: "Paris" },
      output: "Sunny, 22°C"
    };

    const serverMessage: ChatMessage = {
      id: "assistant-approval-merge-1",
      role: "assistant",
      parts: [toolResultPart] as ChatMessage["parts"]
    };

    await agentStub.persistMessages([serverMessage]);

    // Step 2: Client sends the same tool but in approval-responded state
    // (client approved the tool but never received the execution result)
    const clientMessage: ChatMessage = {
      id: "assistant-approval-merge-1",
      role: "assistant",
      parts: [
        {
          type: "tool-getWeather",
          toolCallId: "call_approval_merge_1",
          state: "approval-responded",
          input: { city: "Paris" },
          approval: { id: "approval_1", approved: true }
        } as unknown as ChatMessage["parts"][number]
      ]
    };

    const newUserMsg: ChatMessage = {
      id: "user-approval-merge-1",
      role: "user",
      parts: [{ type: "text", text: "What else?" }]
    };

    await agentStub.persistMessages([clientMessage, newUserMsg]);

    // Step 3: Verify the server's output-available state is preserved,
    // not overwritten by the client's stale approval-responded state
    const persisted = (await agentStub.getPersistedMessages()) as ChatMessage[];

    const assistantMsg = persisted.find(
      (m) => m.id === "assistant-approval-merge-1"
    );
    expect(assistantMsg).toBeDefined();

    const toolPart = assistantMsg!.parts[0] as {
      state: string;
      output?: unknown;
    };
    expect(toolPart.state).toBe("output-available");
    expect(toolPart.output).toBe("Sunny, 22°C");

    ws.close(1000);
  });

  it("preserves server-side tool outputs when client sends approval-requested state", async () => {
    const room = crypto.randomUUID();
    const { ws } = await connectChatWS(`/agents/test-chat-agent/${room}`);
    await new Promise((r) => setTimeout(r, 50));

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

    // Step 1: Server has a tool that was executed (output-available)
    const toolResultPart: TestToolCallPart = {
      type: "tool-getWeather",
      toolCallId: "call_approval_requested_merge_1",
      state: "output-available",
      input: { city: "Tokyo" },
      output: "Clear, 18°C"
    };

    const serverMessage: ChatMessage = {
      id: "assistant-approval-requested-merge-1",
      role: "assistant",
      parts: [toolResultPart] as ChatMessage["parts"]
    };

    await agentStub.persistMessages([serverMessage]);

    // Step 2: Client sends the same tool but in approval-requested state
    // (client reconnected before the approval response was sent)
    const clientMessage: ChatMessage = {
      id: "assistant-approval-requested-merge-1",
      role: "assistant",
      parts: [
        {
          type: "tool-getWeather",
          toolCallId: "call_approval_requested_merge_1",
          state: "approval-requested",
          input: { city: "Tokyo" }
        } as unknown as ChatMessage["parts"][number]
      ]
    };

    const newUserMsg: ChatMessage = {
      id: "user-approval-requested-merge-1",
      role: "user",
      parts: [{ type: "text", text: "Continue" }]
    };

    await agentStub.persistMessages([clientMessage, newUserMsg]);

    // Step 3: Verify the server's output-available state is preserved,
    // not overwritten by the client's stale approval-requested state
    const persisted = (await agentStub.getPersistedMessages()) as ChatMessage[];

    const assistantMsg = persisted.find(
      (m) => m.id === "assistant-approval-requested-merge-1"
    );
    expect(assistantMsg).toBeDefined();

    const toolPart = assistantMsg!.parts[0] as {
      state: string;
      output?: unknown;
    };
    expect(toolPart.state).toBe("output-available");
    expect(toolPart.output).toBe("Clear, 18°C");

    ws.close(1000);
  });

  it("passes through messages unchanged when server has no tool outputs", async () => {
    const room = crypto.randomUUID();
    const { ws } = await connectChatWS(`/agents/test-chat-agent/${room}`);
    await new Promise((r) => setTimeout(r, 50));

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

    const userMessage: ChatMessage = {
      id: "user-no-merge",
      role: "user",
      parts: [{ type: "text", text: "Hello" }]
    };

    const assistantMessage: ChatMessage = {
      id: "assistant-no-merge",
      role: "assistant",
      parts: [{ type: "text", text: "Hi there!" }]
    };

    await agentStub.persistMessages([userMessage, assistantMessage]);

    const persisted = (await agentStub.getPersistedMessages()) as ChatMessage[];
    expect(persisted.length).toBe(2);
    expect(persisted[0].id).toBe("user-no-merge");
    expect(persisted[1].id).toBe("assistant-no-merge");

    ws.close(1000);
  });

  it("reuses server assistant IDs for plain text messages to avoid duplicates", async () => {
    const room = crypto.randomUUID();
    const { ws } = await connectChatWS(`/agents/test-chat-agent/${room}`);
    await new Promise((r) => setTimeout(r, 50));

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

    await agentStub.persistMessages([
      {
        id: "user-1",
        role: "user",
        parts: [{ type: "text", text: "Hello" }]
      },
      {
        id: "assistant_server_1",
        role: "assistant",
        parts: [{ type: "text", text: "Hi there!" }]
      }
    ]);

    await agentStub.persistMessages([
      {
        id: "user-1",
        role: "user",
        parts: [{ type: "text", text: "Hello" }]
      },
      {
        id: "assistant_client_1",
        role: "assistant",
        parts: [{ type: "text", text: "Hi there!" }]
      },
      {
        id: "user-2",
        role: "user",
        parts: [{ type: "text", text: "How are you?" }]
      }
    ]);

    const persisted = (await agentStub.getPersistedMessages()) as ChatMessage[];
    expect(persisted.length).toBe(3);

    const assistantMessages = persisted.filter((m) => m.role === "assistant");
    expect(assistantMessages.length).toBe(1);
    expect(assistantMessages[0].id).toBe("assistant_server_1");
    expect((assistantMessages[0].parts[0] as { text: string }).text).toBe(
      "Hi there!"
    );

    ws.close(1000);
  });

  it("matches repeated assistant text messages in order", async () => {
    const room = crypto.randomUUID();
    const { ws } = await connectChatWS(`/agents/test-chat-agent/${room}`);
    await new Promise((r) => setTimeout(r, 50));

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

    await agentStub.persistMessages([
      {
        id: "user-server-1",
        role: "user",
        parts: [{ type: "text", text: "Say hi" }]
      },
      {
        id: "assistant_server_1",
        role: "assistant",
        parts: [{ type: "text", text: "Hi there!" }]
      },
      {
        id: "user-server-2",
        role: "user",
        parts: [{ type: "text", text: "Say hi again" }]
      },
      {
        id: "assistant_server_2",
        role: "assistant",
        parts: [{ type: "text", text: "Hi there!" }]
      }
    ]);

    await agentStub.persistMessages([
      {
        id: "user-server-1",
        role: "user",
        parts: [{ type: "text", text: "Say hi" }]
      },
      {
        id: "assistant_client_1",
        role: "assistant",
        parts: [{ type: "text", text: "Hi there!" }]
      },
      {
        id: "user-server-2",
        role: "user",
        parts: [{ type: "text", text: "Say hi again" }]
      },
      {
        id: "assistant_client_2",
        role: "assistant",
        parts: [{ type: "text", text: "Hi there!" }]
      }
    ]);

    const persisted = (await agentStub.getPersistedMessages()) as ChatMessage[];

    const assistantMessages = persisted.filter((m) => m.role === "assistant");
    expect(assistantMessages.length).toBe(2);
    expect(assistantMessages[0].id).toBe("assistant_server_1");
    expect(assistantMessages[1].id).toBe("assistant_server_2");

    ws.close(1000);
  });

  it("does not reconcile when assistant content differs at the same position", async () => {
    const room = crypto.randomUUID();
    const { ws } = await connectChatWS(`/agents/test-chat-agent/${room}`);
    await new Promise((r) => setTimeout(r, 50));

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

    await agentStub.persistMessages([
      {
        id: "user-1",
        role: "user",
        parts: [{ type: "text", text: "Hello" }]
      },
      {
        id: "assistant_server_1",
        role: "assistant",
        parts: [{ type: "text", text: "Hi there!" }]
      }
    ]);

    await agentStub.persistMessages([
      {
        id: "user-1",
        role: "user",
        parts: [{ type: "text", text: "Hello" }]
      },
      {
        id: "assistant_client_1",
        role: "assistant",
        parts: [{ type: "text", text: "Completely different response" }]
      }
    ]);

    const persisted = (await agentStub.getPersistedMessages()) as ChatMessage[];

    const assistantMessages = persisted.filter((m) => m.role === "assistant");
    expect(assistantMessages.length).toBe(2);
    expect(assistantMessages.map((m) => m.id)).toContain("assistant_server_1");
    expect(assistantMessages.map((m) => m.id)).toContain("assistant_client_1");

    ws.close(1000);
  });

  it("skips tool-bearing assistant messages during reconciliation without breaking cursor", async () => {
    const room = crypto.randomUUID();
    const { ws } = await connectChatWS(`/agents/test-chat-agent/${room}`);
    await new Promise((r) => setTimeout(r, 50));

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

    const toolPart: TestToolCallPart = {
      type: "tool-getWeather",
      toolCallId: "call_mixed_1",
      state: "output-available",
      input: { city: "London" },
      output: "Rainy, 12°C"
    };

    await agentStub.persistMessages([
      {
        id: "user-1",
        role: "user",
        parts: [{ type: "text", text: "What is the weather?" }]
      },
      {
        id: "assistant_server_tool",
        role: "assistant",
        parts: [toolPart] as ChatMessage["parts"]
      },
      {
        id: "user-2",
        role: "user",
        parts: [{ type: "text", text: "Thanks" }]
      },
      {
        id: "assistant_server_text",
        role: "assistant",
        parts: [{ type: "text", text: "You're welcome!" }]
      }
    ]);

    await agentStub.persistMessages([
      {
        id: "user-1",
        role: "user",
        parts: [{ type: "text", text: "What is the weather?" }]
      },
      {
        id: "assistant_server_tool",
        role: "assistant",
        parts: [toolPart] as ChatMessage["parts"]
      },
      {
        id: "user-2",
        role: "user",
        parts: [{ type: "text", text: "Thanks" }]
      },
      {
        id: "assistant_client_text",
        role: "assistant",
        parts: [{ type: "text", text: "You're welcome!" }]
      }
    ]);

    const persisted = (await agentStub.getPersistedMessages()) as ChatMessage[];

    expect(persisted.length).toBe(4);

    const toolMsg = persisted.find((m) => m.id === "assistant_server_tool");
    expect(toolMsg).toBeDefined();

    const textAssistants = persisted.filter(
      (m) => m.role === "assistant" && m.id !== "assistant_server_tool"
    );
    expect(textAssistants.length).toBe(1);
    expect(textAssistants[0].id).toBe("assistant_server_text");
    expect((textAssistants[0].parts[0] as { text: string }).text).toBe(
      "You're welcome!"
    );

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