import { env, exports } 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"; /** * Helper: send a CF_AGENT_USE_CHAT_REQUEST and wait for the done response. */ function sendChatRequest( ws: WebSocket, requestId: string, messages: ChatMessage[], extraBody?: Record ): Promise { return new Promise((resolve) => { const timeout = setTimeout(() => resolve(false), 3000); const handler = (e: MessageEvent) => { const data = JSON.parse(e.data as string); if ( data.type === MessageType.CF_AGENT_USE_CHAT_RESPONSE && data.id === requestId && data.done ) { clearTimeout(timeout); ws.removeEventListener("message", handler); resolve(true); } }; ws.addEventListener("message", handler); ws.send( JSON.stringify({ type: MessageType.CF_AGENT_USE_CHAT_REQUEST, id: requestId, init: { method: "POST", body: JSON.stringify({ messages, ...extraBody }) } }) ); }); } /** * Helper: fetch persisted messages via HTTP. */ async function fetchPersistedMessages(room: string): Promise { const res = await exports.default.fetch( `http://example.com/agents/test-chat-agent/${room}/get-messages` ); expect(res.status).toBe(200); return (await res.json()) as ChatMessage[]; } describe("Regenerate message (reconcile stale rows)", () => { it("deletes the stale assistant message when client sends truncated array", async () => { const room = crypto.randomUUID(); const { ws } = await connectChatWS(`/agents/test-chat-agent/${room}`); await new Promise((r) => setTimeout(r, 50)); const userMsg: ChatMessage = { id: "user-1", role: "user", parts: [{ type: "text", text: "Hello" }] }; // Step 1: Send the first message — server will respond with an assistant message const done1 = await sendChatRequest(ws, "req-1", [userMsg]); expect(done1).toBe(true); // Verify we have user + assistant in the DB const afterFirst = await fetchPersistedMessages(room); expect(afterFirst.length).toBeGreaterThanOrEqual(2); const assistantMsg = afterFirst.find((m) => m.role === "assistant"); expect(assistantMsg).toBeDefined(); // Step 2: Simulate regenerate() — client removes the assistant message // and sends only the user message (truncated array). const done2 = await sendChatRequest(ws, "req-2", [userMsg]); expect(done2).toBe(true); // Step 3: Verify the old assistant message was deleted and replaced const afterRegenerate = await fetchPersistedMessages(room); const userMessages = afterRegenerate.filter((m) => m.role === "user"); const assistantMessages = afterRegenerate.filter( (m) => m.role === "assistant" ); // Should have exactly 1 user message and 1 new assistant message expect(userMessages.length).toBe(1); expect(userMessages[0].id).toBe("user-1"); expect(assistantMessages.length).toBe(1); // The new assistant message should NOT be the same as the old one // (it was regenerated by the server) expect(assistantMessages[0].id).not.toBe(assistantMsg!.id); ws.close(1000); }); it("does not delete messages when using CF_AGENT_CHAT_MESSAGES (no reconcile)", 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); // Seed the DB with two messages directly const userMsg: ChatMessage = { id: "user-seed", role: "user", parts: [{ type: "text", text: "Hello" }] }; const assistantMsg: ChatMessage = { id: "assistant-seed", role: "assistant", parts: [{ type: "text", text: "Hi there!" }] }; await agentStub.persistMessages([userMsg, assistantMsg]); // Send a CF_AGENT_CHAT_MESSAGES with only a new message (no reconcile) const newMsg: ChatMessage = { id: "new-msg", role: "user", parts: [{ type: "text", text: "Follow up" }] }; ws.send( JSON.stringify({ type: MessageType.CF_AGENT_CHAT_MESSAGES, messages: [newMsg] }) ); await new Promise((r) => setTimeout(r, 100)); // All 3 messages should be preserved — CF_AGENT_CHAT_MESSAGES does not reconcile const persisted = await fetchPersistedMessages(room); expect(persisted.length).toBe(3); expect(persisted.map((m) => m.id)).toContain("user-seed"); expect(persisted.map((m) => m.id)).toContain("assistant-seed"); expect(persisted.map((m) => m.id)).toContain("new-msg"); ws.close(1000); }); it("reconcile deletes stale rows when subset is persisted", 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); // Seed the DB with 3 messages directly const msg1: ChatMessage = { id: "m1", role: "user", parts: [{ type: "text", text: "Hello" }] }; const msg2: ChatMessage = { id: "m2", role: "assistant", parts: [{ type: "text", text: "Hi!" }] }; const msg3: ChatMessage = { id: "m3", role: "user", parts: [{ type: "text", text: "How are you?" }] }; await agentStub.persistMessages([msg1, msg2, msg3]); let persisted = (await agentStub.getPersistedMessages()) as ChatMessage[]; expect(persisted.length).toBe(3); // Persist with reconcile, omitting msg2 (simulates regenerate removing assistant) await agentStub.persistMessages([msg1, msg3], [], { _deleteStaleRows: true }); // msg2 should be deleted persisted = (await agentStub.getPersistedMessages()) as ChatMessage[]; expect(persisted.length).toBe(2); expect(persisted.map((m) => m.id)).toEqual(["m1", "m3"]); ws.close(1000); }); it("reconcile preserves server messages when incoming set contains new IDs", 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); // Seed the DB with a user message and an assistant response const user1: ChatMessage = { id: "u1", role: "user", parts: [{ type: "text", text: "First" }] }; const asst1: ChatMessage = { id: "a1", role: "assistant", parts: [{ type: "text", text: "Reply to first" }] }; await agentStub.persistMessages([user1, asst1]); // Simulate a client that reconnects with partial history and appends // a new user message (does not include the assistant message it never saw) const user2: ChatMessage = { id: "u2", role: "user", parts: [{ type: "text", text: "Second" }] }; await agentStub.persistMessages([user1, user2], [], { _deleteStaleRows: true }); // The assistant message should be preserved — the client sent a new // message ID ("u2") not in the server state, so stale deletion is skipped const persisted = (await agentStub.getPersistedMessages()) as ChatMessage[]; expect(persisted.length).toBe(3); expect(persisted.map((m) => m.id)).toEqual(["u1", "a1", "u2"]); ws.close(1000); }); it("reconcile is a no-op when incoming set matches DB", 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 msg1: ChatMessage = { id: "keep-1", role: "user", parts: [{ type: "text", text: "Hello" }] }; const msg2: ChatMessage = { id: "keep-2", role: "assistant", parts: [{ type: "text", text: "Hi!" }] }; await agentStub.persistMessages([msg1, msg2]); // Persist the same set with reconcile — nothing should change await agentStub.persistMessages([msg1, msg2], [], { _deleteStaleRows: true }); const persisted = (await agentStub.getPersistedMessages()) as ChatMessage[]; expect(persisted.length).toBe(2); expect(persisted.map((m) => m.id)).toEqual(["keep-1", "keep-2"]); ws.close(1000); }); it("trigger field is stripped from options.body (not leaked to user code)", async () => { const room = crypto.randomUUID(); const { ws } = await connectChatWS(`/agents/test-chat-agent/${room}`); const agentStub = await getAgentByName(env.TestChatAgent, room); await agentStub.clearCapturedContext(); const userMsg: ChatMessage = { id: "msg-trigger", role: "user", parts: [{ type: "text", text: "Hello" }] }; // Send with trigger field in the body (as the transport now does) // but no other custom fields const done = await sendChatRequest(ws, "req-trigger", [userMsg], { trigger: "regenerate-message" }); expect(done).toBe(true); await new Promise((r) => setTimeout(r, 100)); // trigger is destructured separately on the server (like messages and // clientTools), so options.body should be undefined when no other // custom fields are present. const capturedBody = await agentStub.getCapturedBody(); expect(capturedBody).toBeUndefined(); ws.close(1000); }); it("trigger field does not pollute options.body when custom fields are present", async () => { const room = crypto.randomUUID(); const { ws } = await connectChatWS(`/agents/test-chat-agent/${room}`); const agentStub = await getAgentByName(env.TestChatAgent, room); await agentStub.clearCapturedContext(); const userMsg: ChatMessage = { id: "msg-trigger-2", role: "user", parts: [{ type: "text", text: "Hello" }] }; // Send with trigger AND a real custom field const done = await sendChatRequest(ws, "req-trigger-2", [userMsg], { trigger: "regenerate-message", model: "claude-sonnet" }); expect(done).toBe(true); await new Promise((r) => setTimeout(r, 100)); // options.body should contain only the custom field, not trigger const capturedBody = await agentStub.getCapturedBody(); expect(capturedBody).toEqual({ model: "claude-sonnet" }); expect(capturedBody).not.toHaveProperty("trigger"); ws.close(1000); }); });