branch:
client-tool-duplicate-message.test.ts
58002 bytesRaw
import { env, exports } from "cloudflare:workers";
import { getAgentByName } from "agents";
import { describe, it, expect } from "vitest";
import type { UIMessage as ChatMessage } from "ai";
import { convertToModelMessages } from "ai";
import {
applyChunkToParts,
type MessageParts,
type StreamChunkData
} from "../message-builder";
describe("Client-side tool duplicate message prevention", () => {
it("merges tool output into existing message by toolCallId", async () => {
const room = crypto.randomUUID();
const res = await exports.default.fetch(
`http://example.com/agents/test-chat-agent/${room}`,
{ headers: { Upgrade: "websocket" } }
);
expect(res.status).toBe(101);
const ws = res.webSocket as WebSocket;
ws.accept();
const agentStub = await getAgentByName(env.TestChatAgent, room);
const toolCallId = "call_merge_test";
// Persist assistant message with tool in input-available state
await agentStub.persistMessages([
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Test" }]
},
{
id: "assistant-original",
role: "assistant",
parts: [
{
type: "tool-testTool",
toolCallId,
state: "input-available",
input: { param: "value" }
}
] as ChatMessage["parts"]
}
]);
// Persist message with different ID but same toolCallId (simulates second stream)
await agentStub.persistMessages([
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Test" }]
},
{
id: "assistant-different-id",
role: "assistant",
parts: [
{
type: "tool-testTool",
toolCallId,
state: "output-available",
input: { param: "value" },
output: "result"
}
] as ChatMessage["parts"]
}
]);
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
const assistantMessages = messages.filter((m) => m.role === "assistant");
// Should have exactly 1 assistant message (merged, not duplicated)
expect(assistantMessages.length).toBe(1);
const toolPart = assistantMessages[0].parts[0] as {
state: string;
output?: unknown;
};
expect(toolPart.state).toBe("output-available");
expect(toolPart.output).toBe("result");
ws.close(1000);
});
it("reconciles client-generated ID with server ID for input-available state (#1094)", async () => {
const room = crypto.randomUUID();
const res = await exports.default.fetch(
`http://example.com/agents/test-chat-agent/${room}`,
{ headers: { Upgrade: "websocket" } }
);
expect(res.status).toBe(101);
const ws = res.webSocket as WebSocket;
ws.accept();
const agentStub = await getAgentByName(env.TestChatAgent, room);
const toolCallId = "call_input_available_reconcile";
await agentStub.persistMessages([
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Test" }]
},
{
id: "assistant_1773224943506_ehnrjoobz",
role: "assistant",
parts: [
{
type: "tool-testTool",
toolCallId,
state: "input-available",
input: { param: "value" }
}
] as ChatMessage["parts"]
}
]);
await agentStub.persistMessages([
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Test" }]
},
{
id: "7VfvgiOu19cSPzLS",
role: "assistant",
parts: [
{
type: "tool-testTool",
toolCallId,
state: "input-available",
input: { param: "value" }
}
] as ChatMessage["parts"]
}
]);
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
const assistantMessages = messages.filter((m) => m.role === "assistant");
expect(assistantMessages.length).toBe(1);
expect(assistantMessages[0].id).toBe("assistant_1773224943506_ehnrjoobz");
ws.close(1000);
});
it("CF_AGENT_TOOL_RESULT applies tool result without auto-continuation by default", async () => {
const room = crypto.randomUUID();
const res = await exports.default.fetch(
`http://example.com/agents/test-chat-agent/${room}`,
{ headers: { Upgrade: "websocket" } }
);
expect(res.status).toBe(101);
const ws = res.webSocket as WebSocket;
ws.accept();
const agentStub = await getAgentByName(env.TestChatAgent, room);
const toolCallId = "call_tool_result_test";
// Persist assistant message with tool in input-available state
await agentStub.persistMessages([
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Execute tool" }]
},
{
id: "assistant-1",
role: "assistant",
parts: [
{
type: "tool-testTool",
toolCallId,
state: "input-available",
input: { param: "value" }
}
] as ChatMessage["parts"]
}
]);
// Send CF_AGENT_TOOL_RESULT via WebSocket WITHOUT autoContinue flag
ws.send(
JSON.stringify({
type: "cf_agent_tool_result",
toolCallId,
toolName: "testTool",
output: { success: true }
// autoContinue not set - should NOT auto-continue
})
);
await new Promise((resolve) => setTimeout(resolve, 200));
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
const assistantMessages = messages.filter((m) => m.role === "assistant");
// Should have exactly 1 assistant message (no auto-continuation)
expect(assistantMessages.length).toBe(1);
const assistantMsg = assistantMessages[0];
expect(assistantMsg.id).toBe("assistant-1");
// Tool result should be applied
const toolPart = assistantMsg.parts[0] as {
state: string;
output?: unknown;
};
expect(toolPart.state).toBe("output-available");
expect(toolPart.output).toEqual({ success: true });
// No continuation parts (only the original tool part)
expect(assistantMsg.parts.length).toBe(1);
ws.close(1000);
});
it("CF_AGENT_TOOL_RESULT auto-continues and merges when autoContinue is true", async () => {
const room = crypto.randomUUID();
const res = await exports.default.fetch(
`http://example.com/agents/test-chat-agent/${room}`,
{ headers: { Upgrade: "websocket" } }
);
expect(res.status).toBe(101);
const ws = res.webSocket as WebSocket;
ws.accept();
const agentStub = await getAgentByName(env.TestChatAgent, room);
const toolCallId = "call_tool_result_auto_continue";
// Persist assistant message with tool in input-available state
await agentStub.persistMessages([
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Execute tool" }]
},
{
id: "assistant-1",
role: "assistant",
parts: [
{
type: "tool-testTool",
toolCallId,
state: "input-available",
input: { param: "value" }
}
] as ChatMessage["parts"]
}
]);
// Send CF_AGENT_TOOL_RESULT with autoContinue: true
ws.send(
JSON.stringify({
type: "cf_agent_tool_result",
toolCallId,
toolName: "testTool",
output: { success: true },
autoContinue: true
})
);
// Wait for tool result to be applied and continuation to happen
// Note: When there's no active stream, the continuation waits 500ms before proceeding
await new Promise((resolve) => setTimeout(resolve, 800));
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
const assistantMessages = messages.filter((m) => m.role === "assistant");
// Should still have exactly 1 assistant message (continuation merged into it)
expect(assistantMessages.length).toBe(1);
const assistantMsg = assistantMessages[0];
expect(assistantMsg.id).toBe("assistant-1");
// First part should be the tool with result applied
const toolPart = assistantMsg.parts[0] as {
state: string;
output?: unknown;
};
expect(toolPart.state).toBe("output-available");
expect(toolPart.output).toEqual({ success: true });
// Continuation parts should be appended (TestChatAgent returns text response)
expect(assistantMsg.parts.length).toBeGreaterThan(1);
ws.close(1000);
});
it("strips OpenAI itemIds from persisted messages to prevent duplicate errors", async () => {
const room = crypto.randomUUID();
const res = await exports.default.fetch(
`http://example.com/agents/test-chat-agent/${room}`,
{ headers: { Upgrade: "websocket" } }
);
expect(res.status).toBe(101);
const ws = res.webSocket as WebSocket;
ws.accept();
const agentStub = await getAgentByName(env.TestChatAgent, room);
// Persist message with OpenAI itemId in providerMetadata (simulates OpenAI Responses API)
await agentStub.persistMessages([
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Hello" }]
},
{
id: "assistant-1",
role: "assistant",
parts: [
{
type: "text",
text: "Hello! How can I help?",
providerMetadata: {
openai: {
itemId: "msg_abc123xyz" // This should be stripped
}
}
}
] as ChatMessage["parts"]
}
]);
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
const assistantMessage = messages.find((m) => m.role === "assistant");
expect(assistantMessage).toBeDefined();
const textPart = assistantMessage!.parts[0] as {
type: string;
text: string;
providerMetadata?: {
openai?: {
itemId?: string;
};
};
};
// The itemId should have been stripped during persistence
expect(textPart.text).toBe("Hello! How can I help?");
expect(textPart.providerMetadata?.openai?.itemId).toBeUndefined();
ws.close(1000);
});
it("strips OpenAI itemIds from tool parts with callProviderMetadata", async () => {
const room = crypto.randomUUID();
const res = await exports.default.fetch(
`http://example.com/agents/test-chat-agent/${room}`,
{ headers: { Upgrade: "websocket" } }
);
expect(res.status).toBe(101);
const ws = res.webSocket as WebSocket;
ws.accept();
const agentStub = await getAgentByName(env.TestChatAgent, room);
const toolCallId = "call_openai_strip_test";
// Persist message with tool that has OpenAI itemId in callProviderMetadata
await agentStub.persistMessages([
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "What time is it?" }]
},
{
id: "assistant-1",
role: "assistant",
parts: [
{
type: "tool-getTime",
toolCallId,
state: "input-available",
input: { timezone: "UTC" },
callProviderMetadata: {
openai: {
itemId: "fc_xyz789" // This should be stripped
}
}
}
] as ChatMessage["parts"]
}
]);
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
const assistantMessage = messages.find((m) => m.role === "assistant");
expect(assistantMessage).toBeDefined();
const toolPart = assistantMessage!.parts[0] as {
type: string;
toolCallId: string;
callProviderMetadata?: {
openai?: {
itemId?: string;
};
};
};
// The itemId should have been stripped during persistence
expect(toolPart.toolCallId).toBe(toolCallId);
expect(toolPart.callProviderMetadata?.openai?.itemId).toBeUndefined();
ws.close(1000);
});
it("preserves other providerMetadata when stripping itemId", async () => {
const room = crypto.randomUUID();
const res = await exports.default.fetch(
`http://example.com/agents/test-chat-agent/${room}`,
{ headers: { Upgrade: "websocket" } }
);
expect(res.status).toBe(101);
const ws = res.webSocket as WebSocket;
ws.accept();
const agentStub = await getAgentByName(env.TestChatAgent, room);
// Persist message with other metadata alongside itemId
await agentStub.persistMessages([
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Hello" }]
},
{
id: "assistant-1",
role: "assistant",
parts: [
{
type: "text",
text: "Hello!",
providerMetadata: {
openai: {
itemId: "msg_strip_me", // Should be stripped
someOtherField: "keep_me" // Should be preserved
},
anthropic: {
someField: "also_keep_me" // Should be preserved
}
}
}
] as ChatMessage["parts"]
}
]);
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
const assistantMessage = messages.find((m) => m.role === "assistant");
expect(assistantMessage).toBeDefined();
const textPart = assistantMessage!.parts[0] as {
type: string;
providerMetadata?: {
openai?: {
itemId?: string;
someOtherField?: string;
};
anthropic?: {
someField?: string;
};
};
};
// itemId should be stripped
expect(textPart.providerMetadata?.openai?.itemId).toBeUndefined();
// Other fields should be preserved
expect(textPart.providerMetadata?.openai?.someOtherField).toBe("keep_me");
expect(textPart.providerMetadata?.anthropic?.someField).toBe(
"also_keep_me"
);
ws.close(1000);
});
it("filters out empty reasoning parts to prevent AI SDK warnings", async () => {
const room = crypto.randomUUID();
const res = await exports.default.fetch(
`http://example.com/agents/test-chat-agent/${room}`,
{ headers: { Upgrade: "websocket" } }
);
expect(res.status).toBe(101);
const ws = res.webSocket as WebSocket;
ws.accept();
const agentStub = await getAgentByName(env.TestChatAgent, room);
// Persist message with empty reasoning part (simulates OpenAI Responses API)
await agentStub.persistMessages([
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Think about this" }]
},
{
id: "assistant-1",
role: "assistant",
parts: [
{
type: "reasoning",
text: "", // Empty reasoning - should be filtered out
providerMetadata: {
openai: {
reasoningEncryptedContent: null
}
}
},
{
type: "text",
text: "Here is my response"
}
] as ChatMessage["parts"]
}
]);
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
const assistantMessage = messages.find((m) => m.role === "assistant");
expect(assistantMessage).toBeDefined();
// Empty reasoning part should have been filtered out
expect(assistantMessage!.parts.length).toBe(1);
expect(assistantMessage!.parts[0].type).toBe("text");
ws.close(1000);
});
it("preserves non-empty reasoning parts", async () => {
const room = crypto.randomUUID();
const res = await exports.default.fetch(
`http://example.com/agents/test-chat-agent/${room}`,
{ headers: { Upgrade: "websocket" } }
);
expect(res.status).toBe(101);
const ws = res.webSocket as WebSocket;
ws.accept();
const agentStub = await getAgentByName(env.TestChatAgent, room);
// Persist message with non-empty reasoning part
await agentStub.persistMessages([
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Think about this" }]
},
{
id: "assistant-1",
role: "assistant",
parts: [
{
type: "reasoning",
text: "Let me think about this carefully...", // Non-empty - should be kept
providerMetadata: {
openai: {
itemId: "reason_123" // But itemId should still be stripped
}
}
},
{
type: "text",
text: "Here is my response"
}
] as ChatMessage["parts"]
}
]);
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
const assistantMessage = messages.find((m) => m.role === "assistant");
expect(assistantMessage).toBeDefined();
// Non-empty reasoning part should be preserved
expect(assistantMessage!.parts.length).toBe(2);
expect(assistantMessage!.parts[0].type).toBe("reasoning");
const reasoningPart = assistantMessage!.parts[0] as {
type: string;
text: string;
providerMetadata?: {
openai?: {
itemId?: string;
};
};
};
expect(reasoningPart.text).toBe("Let me think about this carefully...");
// itemId should still be stripped
expect(reasoningPart.providerMetadata?.openai?.itemId).toBeUndefined();
ws.close(1000);
});
});
describe("Tool approval (needsApproval) duplicate message prevention", () => {
it("CF_AGENT_TOOL_APPROVAL updates existing message in place", async () => {
const room = crypto.randomUUID();
const res = await exports.default.fetch(
`http://example.com/agents/test-chat-agent/${room}`,
{ headers: { Upgrade: "websocket" } }
);
expect(res.status).toBe(101);
const ws = res.webSocket as WebSocket;
ws.accept();
const agentStub = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
const toolCallId = "call_approval_test";
// Persist assistant message with tool in input-available state (needs approval)
await agentStub.persistMessages([
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Execute tool" }]
},
{
id: "assistant-1",
role: "assistant",
parts: [
{
type: "tool-testTool",
toolCallId,
state: "input-available",
input: { param: "value" }
}
] as ChatMessage["parts"]
}
]);
// Send CF_AGENT_TOOL_APPROVAL via WebSocket
ws.send(
JSON.stringify({
type: "cf_agent_tool_approval",
toolCallId,
approved: true
})
);
await new Promise((resolve) => setTimeout(resolve, 200));
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
const assistantMessages = messages.filter((m) => m.role === "assistant");
// Should have exactly 1 assistant message (updated in place, not duplicated)
expect(assistantMessages.length).toBe(1);
const assistantMsg = assistantMessages[0];
// Message ID should be preserved
expect(assistantMsg.id).toBe("assistant-1");
// Tool state should be updated to approval-responded
const toolPart = assistantMsg.parts[0] as {
state: string;
approval?: { approved: boolean };
};
expect(toolPart.state).toBe("approval-responded");
expect(toolPart.approval).toEqual({ approved: true });
ws.close(1000);
});
it("CF_AGENT_TOOL_APPROVAL handles rejection (approved: false)", async () => {
const room = crypto.randomUUID();
const res = await exports.default.fetch(
`http://example.com/agents/test-chat-agent/${room}`,
{ headers: { Upgrade: "websocket" } }
);
expect(res.status).toBe(101);
const ws = res.webSocket as WebSocket;
ws.accept();
const agentStub = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
const toolCallId = "call_rejection_test";
// Persist assistant message with tool in input-available state
await agentStub.persistMessages([
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Execute tool" }]
},
{
id: "assistant-1",
role: "assistant",
parts: [
{
type: "tool-testTool",
toolCallId,
state: "input-available",
input: { param: "value" }
}
] as ChatMessage["parts"]
}
]);
// Send CF_AGENT_TOOL_APPROVAL with approved: false (rejection)
ws.send(
JSON.stringify({
type: "cf_agent_tool_approval",
toolCallId,
approved: false
})
);
await new Promise((resolve) => setTimeout(resolve, 200));
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
const assistantMessages = messages.filter((m) => m.role === "assistant");
expect(assistantMessages.length).toBe(1);
const toolPart = assistantMessages[0].parts[0] as {
state: string;
approval?: { approved: boolean };
};
expect(toolPart.state).toBe("output-denied");
expect(toolPart.approval).toEqual({ approved: false });
ws.close(1000);
});
it("CF_AGENT_TOOL_APPROVAL updates tool in approval-requested state", async () => {
const room = crypto.randomUUID();
const res = await exports.default.fetch(
`http://example.com/agents/test-chat-agent/${room}`,
{ headers: { Upgrade: "websocket" } }
);
expect(res.status).toBe(101);
const ws = res.webSocket as WebSocket;
ws.accept();
const agentStub = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
const toolCallId = "call_approval_requested_test";
// Persist assistant message with tool in approval-requested state
await agentStub.persistMessages([
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Execute tool" }]
},
{
id: "assistant-1",
role: "assistant",
parts: [
{
type: "tool-testTool",
toolCallId,
state: "approval-requested",
input: { param: "value" },
approval: { id: "approval-123" }
}
] as ChatMessage["parts"]
}
]);
// Send CF_AGENT_TOOL_APPROVAL
ws.send(
JSON.stringify({
type: "cf_agent_tool_approval",
toolCallId,
approved: true
})
);
await new Promise((resolve) => setTimeout(resolve, 200));
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
const assistantMessages = messages.filter((m) => m.role === "assistant");
expect(assistantMessages.length).toBe(1);
const toolPart = assistantMessages[0].parts[0] as {
state: string;
approval?: { approved: boolean };
};
expect(toolPart.state).toBe("approval-responded");
// approval.id is preserved from the approval-requested state
expect(toolPart.approval).toEqual({ id: "approval-123", approved: true });
ws.close(1000);
});
it("CF_AGENT_TOOL_APPROVAL rejection from approval-requested sets output-denied", async () => {
const room = crypto.randomUUID();
const res = await exports.default.fetch(
`http://example.com/agents/test-chat-agent/${room}`,
{ headers: { Upgrade: "websocket" } }
);
expect(res.status).toBe(101);
const ws = res.webSocket as WebSocket;
ws.accept();
const agentStub = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
const toolCallId = "call_approval_requested_rejected";
await agentStub.persistMessages([
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Execute tool" }]
},
{
id: "assistant-1",
role: "assistant",
parts: [
{
type: "tool-testTool",
toolCallId,
state: "approval-requested",
input: { param: "value" },
approval: { id: "approval-789" }
}
] as ChatMessage["parts"]
}
]);
ws.send(
JSON.stringify({
type: "cf_agent_tool_approval",
toolCallId,
approved: false
})
);
await new Promise((resolve) => setTimeout(resolve, 200));
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
const assistantMessages = messages.filter((m) => m.role === "assistant");
expect(assistantMessages.length).toBe(1);
const toolPart = assistantMessages[0].parts[0] as {
state: string;
approval?: { id: string; approved: boolean };
};
expect(toolPart.state).toBe("output-denied");
expect(toolPart.approval).toEqual({ id: "approval-789", approved: false });
ws.close(1000);
});
it("CF_AGENT_TOOL_APPROVAL with non-existent toolCallId logs warning", async () => {
const room = crypto.randomUUID();
const res = await exports.default.fetch(
`http://example.com/agents/test-chat-agent/${room}`,
{ headers: { Upgrade: "websocket" } }
);
expect(res.status).toBe(101);
const ws = res.webSocket as WebSocket;
ws.accept();
const agentStub = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
// Persist a message without any tool calls
await agentStub.persistMessages([
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Hello" }]
},
{
id: "assistant-1",
role: "assistant",
parts: [{ type: "text", text: "Hi there!" }] as ChatMessage["parts"]
}
]);
// Send CF_AGENT_TOOL_APPROVAL for non-existent tool
ws.send(
JSON.stringify({
type: "cf_agent_tool_approval",
toolCallId: "non_existent_tool_call",
approved: true
})
);
await new Promise((resolve) => setTimeout(resolve, 1200)); // Wait for retries (10 * 100ms + buffer)
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
// Messages should remain unchanged (no crash, graceful handling)
expect(messages.length).toBe(2);
const assistantMsg = messages.find((m) => m.role === "assistant");
expect(assistantMsg?.parts[0]).toEqual({ type: "text", text: "Hi there!" });
ws.close(1000);
});
it("CF_AGENT_TOOL_APPROVAL does not update tool already in output-available state", async () => {
const room = crypto.randomUUID();
const res = await exports.default.fetch(
`http://example.com/agents/test-chat-agent/${room}`,
{ headers: { Upgrade: "websocket" } }
);
expect(res.status).toBe(101);
const ws = res.webSocket as WebSocket;
ws.accept();
const agentStub = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
const toolCallId = "call_already_completed";
// Persist assistant message with tool already in output-available state
await agentStub.persistMessages([
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Execute tool" }]
},
{
id: "assistant-1",
role: "assistant",
parts: [
{
type: "tool-testTool",
toolCallId,
state: "output-available",
input: { param: "value" },
output: { result: "done" }
}
] as ChatMessage["parts"]
}
]);
// Send CF_AGENT_TOOL_APPROVAL for tool that's already completed
ws.send(
JSON.stringify({
type: "cf_agent_tool_approval",
toolCallId,
approved: true
})
);
await new Promise((resolve) => setTimeout(resolve, 200));
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
const assistantMessages = messages.filter((m) => m.role === "assistant");
expect(assistantMessages.length).toBe(1);
// State should remain output-available (not changed to approval-responded)
const toolPart = assistantMessages[0].parts[0] as {
state: string;
output?: unknown;
};
expect(toolPart.state).toBe("output-available");
expect(toolPart.output).toEqual({ result: "done" });
ws.close(1000);
});
});
describe("Tool approval auto-continuation (needsApproval)", () => {
it("CF_AGENT_TOOL_APPROVAL without autoContinue does NOT trigger continuation", async () => {
const room = crypto.randomUUID();
const res = await exports.default.fetch(
`http://example.com/agents/test-chat-agent/${room}`,
{ headers: { Upgrade: "websocket" } }
);
expect(res.status).toBe(101);
const ws = res.webSocket as WebSocket;
ws.accept();
const agentStub = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
const toolCallId = "call_no_auto_continue";
// Persist assistant message with tool in input-available state
await agentStub.persistMessages([
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Execute tool" }]
},
{
id: "assistant-1",
role: "assistant",
parts: [
{
type: "tool-testTool",
toolCallId,
state: "input-available",
input: { param: "value" }
}
] as ChatMessage["parts"]
}
]);
// Send approval WITHOUT autoContinue
ws.send(
JSON.stringify({
type: "cf_agent_tool_approval",
toolCallId,
approved: true
// no autoContinue
})
);
await new Promise((resolve) => setTimeout(resolve, 800));
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
const assistantMessages = messages.filter((m) => m.role === "assistant");
// Should have exactly 1 assistant message (no continuation)
expect(assistantMessages.length).toBe(1);
const assistantMsg = assistantMessages[0];
expect(assistantMsg.id).toBe("assistant-1");
// Tool state should be approval-responded but no continuation parts
const toolPart = assistantMsg.parts[0] as {
state: string;
approval?: { approved: boolean };
};
expect(toolPart.state).toBe("approval-responded");
expect(toolPart.approval).toEqual({ approved: true });
expect(assistantMsg.parts.length).toBe(1);
ws.close(1000);
});
it("CF_AGENT_TOOL_APPROVAL with autoContinue triggers continuation", async () => {
const room = crypto.randomUUID();
const res = await exports.default.fetch(
`http://example.com/agents/test-chat-agent/${room}`,
{ headers: { Upgrade: "websocket" } }
);
expect(res.status).toBe(101);
const ws = res.webSocket as WebSocket;
ws.accept();
const agentStub = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
const toolCallId = "call_auto_continue_approval";
// Persist assistant message with tool in input-available state
await agentStub.persistMessages([
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Execute tool" }]
},
{
id: "assistant-1",
role: "assistant",
parts: [
{
type: "tool-testTool",
toolCallId,
state: "input-available",
input: { param: "value" }
}
] as ChatMessage["parts"]
}
]);
// Send approval WITH autoContinue: true
ws.send(
JSON.stringify({
type: "cf_agent_tool_approval",
toolCallId,
approved: true,
autoContinue: true
})
);
// Wait for approval + continuation (500ms wait + stream)
await new Promise((resolve) => setTimeout(resolve, 800));
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
const assistantMessages = messages.filter((m) => m.role === "assistant");
// Should still have 1 assistant message (continuation merged into it)
expect(assistantMessages.length).toBe(1);
const assistantMsg = assistantMessages[0];
expect(assistantMsg.id).toBe("assistant-1");
// First part should be the approved tool
const toolPart = assistantMsg.parts[0] as {
state: string;
approval?: { approved: boolean };
};
expect(toolPart.state).toBe("approval-responded");
expect(toolPart.approval).toEqual({ approved: true });
// Continuation parts should be appended (TestChatAgent returns text response)
expect(assistantMsg.parts.length).toBeGreaterThan(1);
ws.close(1000);
});
it("CF_AGENT_TOOL_APPROVAL with approved: false and autoContinue triggers continuation", async () => {
const room = crypto.randomUUID();
const res = await exports.default.fetch(
`http://example.com/agents/test-chat-agent/${room}`,
{ headers: { Upgrade: "websocket" } }
);
expect(res.status).toBe(101);
const ws = res.webSocket as WebSocket;
ws.accept();
const agentStub = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
const toolCallId = "call_rejected_continue";
await agentStub.persistMessages([
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Execute tool" }]
},
{
id: "assistant-1",
role: "assistant",
parts: [
{
type: "tool-testTool",
toolCallId,
state: "input-available",
input: { param: "value" }
}
] as ChatMessage["parts"]
}
]);
ws.send(
JSON.stringify({
type: "cf_agent_tool_approval",
toolCallId,
approved: false,
autoContinue: true
})
);
await new Promise((resolve) => setTimeout(resolve, 800));
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
const assistantMessages = messages.filter((m) => m.role === "assistant");
expect(assistantMessages.length).toBe(1);
const assistantMsg = assistantMessages[0];
const toolPart = assistantMsg.parts[0] as {
state: string;
approval?: { approved: boolean };
};
expect(toolPart.state).toBe("output-denied");
expect(toolPart.approval).toEqual({ approved: false });
// Continuation parts should be appended (LLM sees denial and responds)
expect(assistantMsg.parts.length).toBeGreaterThan(1);
ws.close(1000);
});
it("CF_AGENT_TOOL_APPROVAL with autoContinue on approval-requested state triggers continuation", async () => {
const room = crypto.randomUUID();
const res = await exports.default.fetch(
`http://example.com/agents/test-chat-agent/${room}`,
{ headers: { Upgrade: "websocket" } }
);
expect(res.status).toBe(101);
const ws = res.webSocket as WebSocket;
ws.accept();
const agentStub = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
const toolCallId = "call_approval_requested_continue";
// Persist assistant message with tool in approval-requested state
await agentStub.persistMessages([
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Execute tool" }]
},
{
id: "assistant-1",
role: "assistant",
parts: [
{
type: "tool-testTool",
toolCallId,
state: "approval-requested",
input: { param: "value" },
approval: { id: "approval-456" }
}
] as ChatMessage["parts"]
}
]);
// Send approval with autoContinue
ws.send(
JSON.stringify({
type: "cf_agent_tool_approval",
toolCallId,
approved: true,
autoContinue: true
})
);
await new Promise((resolve) => setTimeout(resolve, 800));
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
const assistantMessages = messages.filter((m) => m.role === "assistant");
expect(assistantMessages.length).toBe(1);
const assistantMsg = assistantMessages[0];
// Tool part should be updated
const toolPart = assistantMsg.parts[0] as {
state: string;
approval?: { approved: boolean };
};
expect(toolPart.state).toBe("approval-responded");
expect(toolPart.approval).toEqual({ id: "approval-456", approved: true });
// Continuation should have appended parts
expect(assistantMsg.parts.length).toBeGreaterThan(1);
ws.close(1000);
});
});
describe("applyChunkToParts: tool-approval-request", () => {
it("transitions tool part from input-available to approval-requested", () => {
const parts: MessageParts = [
{
type: "tool-calculate",
toolCallId: "call_123",
toolName: "calculate",
state: "input-available",
input: { a: 5000, b: 3, operator: "*" }
} as MessageParts[number]
];
const handled = applyChunkToParts(parts, {
type: "tool-approval-request",
approvalId: "approval-abc",
toolCallId: "call_123"
} as StreamChunkData);
expect(handled).toBe(true);
const part = parts[0] as Record<string, unknown>;
expect(part.state).toBe("approval-requested");
expect(part.approval).toEqual({ id: "approval-abc" });
// Input should be preserved
expect(part.input).toEqual({ a: 5000, b: 3, operator: "*" });
});
it("does nothing if tool part not found", () => {
const parts: MessageParts = [];
const handled = applyChunkToParts(parts, {
type: "tool-approval-request",
approvalId: "approval-abc",
toolCallId: "call_nonexistent"
} as StreamChunkData);
expect(handled).toBe(true);
expect(parts.length).toBe(0);
});
});
describe("applyChunkToParts: tool-output-denied", () => {
it("transitions tool part to output-denied state", () => {
const parts: MessageParts = [
{
type: "tool-calculate",
toolCallId: "call_456",
toolName: "calculate",
state: "approval-requested",
input: { a: 5000, b: 3, operator: "*" },
approval: { id: "approval-xyz" }
} as MessageParts[number]
];
const handled = applyChunkToParts(parts, {
type: "tool-output-denied",
toolCallId: "call_456"
} as StreamChunkData);
expect(handled).toBe(true);
const part = parts[0] as Record<string, unknown>;
expect(part.state).toBe("output-denied");
// Input and approval should be preserved
expect(part.input).toEqual({ a: 5000, b: 3, operator: "*" });
expect(part.approval).toEqual({ id: "approval-xyz" });
});
});
describe("Tool approval persistence across reconnect", () => {
it("persisted messages include approval-requested state after approval-request chunk", async () => {
const room = crypto.randomUUID();
const res = await exports.default.fetch(
`http://example.com/agents/test-chat-agent/${room}`,
{ headers: { Upgrade: "websocket" } }
);
expect(res.status).toBe(101);
const ws = res.webSocket as WebSocket;
ws.accept();
const agentStub = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
const toolCallId = "call_persist_approval_test";
// Manually persist messages that simulate the state after
// a tool-approval-request was received and early-persisted.
// In a real flow, the streaming handler would do this.
await agentStub.persistMessages([
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Calculate 5000 * 3" }]
},
{
id: "assistant-1",
role: "assistant",
parts: [
{
type: "tool-calculate",
toolCallId,
state: "approval-requested",
input: { a: 5000, b: 3, operator: "*" },
approval: { id: "approval-persist-test" }
}
] as ChatMessage["parts"]
}
]);
// Verify the messages were persisted with approval-requested state
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
const assistantMessages = messages.filter((m) => m.role === "assistant");
expect(assistantMessages.length).toBe(1);
const toolPart = assistantMessages[0].parts[0] as Record<string, unknown>;
expect(toolPart.state).toBe("approval-requested");
expect(toolPart.approval).toEqual({ id: "approval-persist-test" });
// Now simulate a client reconnecting and approving the tool.
// A new client would receive these persisted messages and see the approval UI.
// When they approve, CF_AGENT_TOOL_APPROVAL is sent.
ws.send(
JSON.stringify({
type: "cf_agent_tool_approval",
toolCallId,
approved: true
})
);
await new Promise((resolve) => setTimeout(resolve, 200));
const updatedMessages =
(await agentStub.getPersistedMessages()) as ChatMessage[];
const updatedAssistant = updatedMessages.filter(
(m) => m.role === "assistant"
);
expect(updatedAssistant.length).toBe(1);
const updatedToolPart = updatedAssistant[0].parts[0] as Record<
string,
unknown
>;
expect(updatedToolPart.state).toBe("approval-responded");
// approval.id preserved from the approval-requested state
expect(updatedToolPart.approval).toEqual({
id: "approval-persist-test",
approved: true
});
ws.close(1000);
});
});
describe("Tool approval denial produces tool_result via convertToModelMessages", () => {
it("rejected approval yields tool-result in model messages (required by Anthropic)", async () => {
const room = crypto.randomUUID();
const res = await exports.default.fetch(
`http://example.com/agents/test-chat-agent/${room}`,
{ headers: { Upgrade: "websocket" } }
);
expect(res.status).toBe(101);
const ws = res.webSocket as WebSocket;
ws.accept();
const agentStub = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
const toolCallId = "call_e2e_denied";
await agentStub.persistMessages([
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Run the tool" }]
},
{
id: "assistant-1",
role: "assistant",
parts: [
{
type: "tool-testTool",
toolCallId,
state: "approval-requested",
input: { param: "value" },
approval: { id: "approval-e2e" }
}
] as ChatMessage["parts"]
}
]);
ws.send(
JSON.stringify({
type: "cf_agent_tool_approval",
toolCallId,
approved: false
})
);
await new Promise((resolve) => setTimeout(resolve, 200));
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
const modelMessages = await convertToModelMessages(messages);
const toolMessage = modelMessages.find((m) => m.role === "tool");
expect(toolMessage).toBeDefined();
const toolContent = toolMessage!.content as Array<{
type: string;
[key: string]: unknown;
}>;
const approvalResponse = toolContent.find(
(c) => c.type === "tool-approval-response"
);
expect(approvalResponse).toBeDefined();
expect(approvalResponse!.approved).toBe(false);
const toolResult = toolContent.find((c) => c.type === "tool-result");
expect(toolResult).toBeDefined();
expect(toolResult!.toolCallId).toBe(toolCallId);
expect(toolResult!.toolName).toBe("testTool");
ws.close(1000);
});
});
describe("CF_AGENT_TOOL_RESULT with approval states and output-error", () => {
it("applies tool result to a tool in approval-requested state", async () => {
const room = crypto.randomUUID();
const res = await exports.default.fetch(
`http://example.com/agents/test-chat-agent/${room}`,
{ headers: { Upgrade: "websocket" } }
);
expect(res.status).toBe(101);
const ws = res.webSocket as WebSocket;
ws.accept();
const agentStub = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
const toolCallId = "call_approval_tool_result";
await agentStub.persistMessages([
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Execute tool" }]
},
{
id: "assistant-1",
role: "assistant",
parts: [
{
type: "tool-testTool",
toolCallId,
state: "approval-requested",
input: { param: "value" },
approval: { id: "approval-tr-1" }
}
] as ChatMessage["parts"]
}
]);
ws.send(
JSON.stringify({
type: "cf_agent_tool_result",
toolCallId,
toolName: "testTool",
output: { result: "done" }
})
);
await new Promise((resolve) => setTimeout(resolve, 200));
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
const assistantMessages = messages.filter((m) => m.role === "assistant");
expect(assistantMessages.length).toBe(1);
const toolPart = assistantMessages[0].parts[0] as {
state: string;
output?: unknown;
};
expect(toolPart.state).toBe("output-available");
expect(toolPart.output).toEqual({ result: "done" });
ws.close(1000);
});
it("sets output-error state with errorText via CF_AGENT_TOOL_RESULT", async () => {
const room = crypto.randomUUID();
const res = await exports.default.fetch(
`http://example.com/agents/test-chat-agent/${room}`,
{ headers: { Upgrade: "websocket" } }
);
expect(res.status).toBe(101);
const ws = res.webSocket as WebSocket;
ws.accept();
const agentStub = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
const toolCallId = "call_output_error";
await agentStub.persistMessages([
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Execute tool" }]
},
{
id: "assistant-1",
role: "assistant",
parts: [
{
type: "tool-testTool",
toolCallId,
state: "approval-requested",
input: { param: "value" },
approval: { id: "approval-err-1" }
}
] as ChatMessage["parts"]
}
]);
ws.send(
JSON.stringify({
type: "cf_agent_tool_result",
toolCallId,
toolName: "testTool",
output: null,
state: "output-error",
errorText: "User declined: not authorized"
})
);
await new Promise((resolve) => setTimeout(resolve, 200));
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
const assistantMessages = messages.filter((m) => m.role === "assistant");
expect(assistantMessages.length).toBe(1);
const toolPart = assistantMessages[0].parts[0] as {
state: string;
errorText?: string;
};
expect(toolPart.state).toBe("output-error");
expect(toolPart.errorText).toBe("User declined: not authorized");
ws.close(1000);
});
it("output-error produces tool_result with custom errorText in model messages", async () => {
const room = crypto.randomUUID();
const res = await exports.default.fetch(
`http://example.com/agents/test-chat-agent/${room}`,
{ headers: { Upgrade: "websocket" } }
);
expect(res.status).toBe(101);
const ws = res.webSocket as WebSocket;
ws.accept();
const agentStub = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
const toolCallId = "call_e2e_error";
await agentStub.persistMessages([
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Run the tool" }]
},
{
id: "assistant-1",
role: "assistant",
parts: [
{
type: "tool-testTool",
toolCallId,
state: "input-available",
input: { param: "value" }
}
] as ChatMessage["parts"]
}
]);
ws.send(
JSON.stringify({
type: "cf_agent_tool_result",
toolCallId,
toolName: "testTool",
output: null,
state: "output-error",
errorText: "Denied: insufficient permissions"
})
);
await new Promise((resolve) => setTimeout(resolve, 200));
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
const modelMessages = await convertToModelMessages(messages);
const toolMessage = modelMessages.find((m) => m.role === "tool");
expect(toolMessage).toBeDefined();
const toolContent = toolMessage!.content as Array<{
type: string;
[key: string]: unknown;
}>;
const toolResult = toolContent.find((c) => c.type === "tool-result");
expect(toolResult).toBeDefined();
expect(toolResult!.toolCallId).toBe(toolCallId);
const output = toolResult!.output as { type: string; value: string };
expect(output.type).toBe("error-text");
expect(output.value).toBe("Denied: insufficient permissions");
ws.close(1000);
});
it("CF_AGENT_TOOL_RESULT does not update tool already in output-denied state", async () => {
const room = crypto.randomUUID();
const res = await exports.default.fetch(
`http://example.com/agents/test-chat-agent/${room}`,
{ headers: { Upgrade: "websocket" } }
);
expect(res.status).toBe(101);
const ws = res.webSocket as WebSocket;
ws.accept();
const agentStub = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
const toolCallId = "call_already_denied";
await agentStub.persistMessages([
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Execute tool" }]
},
{
id: "assistant-1",
role: "assistant",
parts: [
{
type: "tool-testTool",
toolCallId,
state: "output-denied",
input: { param: "value" },
approval: { id: "approval-denied-guard", approved: false }
}
] as ChatMessage["parts"]
}
]);
ws.send(
JSON.stringify({
type: "cf_agent_tool_result",
toolCallId,
toolName: "testTool",
output: { result: "override attempt" }
})
);
await new Promise((resolve) => setTimeout(resolve, 200));
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
const assistantMessages = messages.filter((m) => m.role === "assistant");
expect(assistantMessages.length).toBe(1);
const toolPart = assistantMessages[0].parts[0] as {
state: string;
};
expect(toolPart.state).toBe("output-denied");
ws.close(1000);
});
it("CF_AGENT_TOOL_RESULT does not update tool already in output-available state", async () => {
const room = crypto.randomUUID();
const res = await exports.default.fetch(
`http://example.com/agents/test-chat-agent/${room}`,
{ headers: { Upgrade: "websocket" } }
);
expect(res.status).toBe(101);
const ws = res.webSocket as WebSocket;
ws.accept();
const agentStub = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
const toolCallId = "call_already_completed_tr";
await agentStub.persistMessages([
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Execute tool" }]
},
{
id: "assistant-1",
role: "assistant",
parts: [
{
type: "tool-testTool",
toolCallId,
state: "output-available",
input: { param: "value" },
output: { result: "original" }
}
] as ChatMessage["parts"]
}
]);
ws.send(
JSON.stringify({
type: "cf_agent_tool_result",
toolCallId,
toolName: "testTool",
output: { result: "override attempt" }
})
);
await new Promise((resolve) => setTimeout(resolve, 200));
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
const assistantMessages = messages.filter((m) => m.role === "assistant");
expect(assistantMessages.length).toBe(1);
const toolPart = assistantMessages[0].parts[0] as {
state: string;
output?: unknown;
};
expect(toolPart.state).toBe("output-available");
expect(toolPart.output).toEqual({ result: "original" });
ws.close(1000);
});
it("applies tool result to a tool in approval-responded state", async () => {
const room = crypto.randomUUID();
const res = await exports.default.fetch(
`http://example.com/agents/test-chat-agent/${room}`,
{ headers: { Upgrade: "websocket" } }
);
expect(res.status).toBe(101);
const ws = res.webSocket as WebSocket;
ws.accept();
const agentStub = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
const toolCallId = "call_responded_tool_result";
await agentStub.persistMessages([
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Execute tool" }]
},
{
id: "assistant-1",
role: "assistant",
parts: [
{
type: "tool-testTool",
toolCallId,
state: "approval-responded",
input: { param: "value" },
approval: { id: "approval-resp-1", approved: true }
}
] as ChatMessage["parts"]
}
]);
ws.send(
JSON.stringify({
type: "cf_agent_tool_result",
toolCallId,
toolName: "testTool",
output: { result: "custom result" }
})
);
await new Promise((resolve) => setTimeout(resolve, 200));
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
const assistantMessages = messages.filter((m) => m.role === "assistant");
expect(assistantMessages.length).toBe(1);
const toolPart = assistantMessages[0].parts[0] as {
state: string;
output?: unknown;
};
expect(toolPart.state).toBe("output-available");
expect(toolPart.output).toEqual({ result: "custom result" });
ws.close(1000);
});
it("output-error without errorText uses default message", async () => {
const room = crypto.randomUUID();
const res = await exports.default.fetch(
`http://example.com/agents/test-chat-agent/${room}`,
{ headers: { Upgrade: "websocket" } }
);
expect(res.status).toBe(101);
const ws = res.webSocket as WebSocket;
ws.accept();
const agentStub = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
const toolCallId = "call_default_error";
await agentStub.persistMessages([
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Execute tool" }]
},
{
id: "assistant-1",
role: "assistant",
parts: [
{
type: "tool-testTool",
toolCallId,
state: "input-available",
input: { param: "value" }
}
] as ChatMessage["parts"]
}
]);
ws.send(
JSON.stringify({
type: "cf_agent_tool_result",
toolCallId,
toolName: "testTool",
output: null,
state: "output-error"
})
);
await new Promise((resolve) => setTimeout(resolve, 200));
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
const assistantMessages = messages.filter((m) => m.role === "assistant");
expect(assistantMessages.length).toBe(1);
const toolPart = assistantMessages[0].parts[0] as {
state: string;
errorText?: string;
};
expect(toolPart.state).toBe("output-error");
expect(toolPart.errorText).toBe("Tool execution denied by user");
ws.close(1000);
});
it("output-error on approval-responded produces tool_result via convertToModelMessages", async () => {
const room = crypto.randomUUID();
const res = await exports.default.fetch(
`http://example.com/agents/test-chat-agent/${room}`,
{ headers: { Upgrade: "websocket" } }
);
expect(res.status).toBe(101);
const ws = res.webSocket as WebSocket;
ws.accept();
const agentStub = env.TestChatAgent.get(env.TestChatAgent.idFromName(room));
const toolCallId = "call_e2e_responded_error";
await agentStub.persistMessages([
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Run the tool" }]
},
{
id: "assistant-1",
role: "assistant",
parts: [
{
type: "tool-testTool",
toolCallId,
state: "approval-responded",
input: { param: "value" },
approval: { id: "approval-e2e-2", approved: true }
}
] as ChatMessage["parts"]
}
]);
ws.send(
JSON.stringify({
type: "cf_agent_tool_result",
toolCallId,
toolName: "testTool",
output: null,
state: "output-error",
errorText: "Execution failed: timeout"
})
);
await new Promise((resolve) => setTimeout(resolve, 200));
const messages = (await agentStub.getPersistedMessages()) as ChatMessage[];
const modelMessages = await convertToModelMessages(messages);
const toolMessage = modelMessages.find((m) => m.role === "tool");
expect(toolMessage).toBeDefined();
const toolContent = toolMessage!.content as Array<{
type: string;
[key: string]: unknown;
}>;
const toolResult = toolContent.find((c) => c.type === "tool-result");
expect(toolResult).toBeDefined();
expect(toolResult!.toolCallId).toBe(toolCallId);
const output = toolResult!.output as { type: string; value: string };
expect(output.type).toBe("error-text");
expect(output.value).toBe("Execution failed: timeout");
ws.close(1000);
});
});