branch:
regenerate-message.test.ts
10813 bytesRaw
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<string, unknown>
): Promise<boolean> {
return new Promise<boolean>((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<ChatMessage[]> {
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);
});
});