import { describe, expect, it } from "bun:test"; import { contextToMessages, mapStreamPart } from "../src/agent/agent.ts"; import type { ContextMessage } from "../src/agent/memory.ts"; describe("mapStreamPart toolCallId propagation", () => { it("tool-input-start includes toolCallId", () => { const tracked = new Map(); const event = mapStreamPart( { type: "tool-input-start", id: "call_abc", toolName: "send" } as any, tracked, ); expect(event).not.toBeNull(); expect(event!.type).toBe("tool_start"); expect(event!.toolCallId).toBe("call_abc"); expect(event!.text).toBe("send"); }); it("tool-input-delta includes toolCallId for streamed tools", () => { const tracked = new Map(); tracked.set("call_xyz", "write_file"); const event = mapStreamPart( { type: "tool-input-delta", id: "call_xyz", delta: "some code" } as any, tracked, ); expect(event).not.toBeNull(); expect(event!.type).toBe("tool_delta"); expect(event!.toolCallId).toBe("call_xyz"); expect(event!.text).toBe("some code"); }); it("tool-input-delta returns null for non-streamed tools", () => { const tracked = new Map(); tracked.set("call_xyz", "remember"); const event = mapStreamPart( { type: "tool-input-delta", id: "call_xyz", delta: "data" } as any, tracked, ); expect(event).toBeNull(); }); it("tool-result includes toolCallId and toolName", () => { const tracked = new Map(); const event = mapStreamPart( { type: "tool-result", toolCallId: "call_123", toolName: "send", output: "response text" } as any, tracked, ); expect(event).not.toBeNull(); expect(event!.type).toBe("tool_result"); expect(event!.toolCallId).toBe("call_123"); expect(event!.toolName).toBe("send"); expect(event!.text).toBe("response text"); }); it("tool-result serializes non-string output", () => { const tracked = new Map(); const event = mapStreamPart( { type: "tool-result", toolCallId: "call_456", toolName: "remember", output: { ok: true } } as any, tracked, ); expect(event!.text).toBe('{"ok":true}'); expect(event!.toolCallId).toBe("call_456"); expect(event!.toolName).toBe("remember"); }); it("tool-error includes toolCallId and toolName", () => { const tracked = new Map(); const event = mapStreamPart( { type: "tool-error", toolCallId: "call_err", toolName: "send", error: new Error("timeout") } as any, tracked, ); expect(event).not.toBeNull(); expect(event!.type).toBe("tool_result"); expect(event!.toolCallId).toBe("call_err"); expect(event!.toolName).toBe("send"); expect(event!.text).toContain("timeout"); }); it("tool-call preserves existing toolUse.id", () => { const tracked = new Map(); const event = mapStreamPart( { type: "tool-call", toolCallId: "call_tc", toolName: "send", input: { agent_id: "b" } } as any, tracked, ); expect(event!.type).toBe("tool_use"); expect(event!.toolUse!.id).toBe("call_tc"); expect(event!.toolUse!.name).toBe("send"); }); it("error event classifies APICallError with statusCode", () => { const tracked = new Map(); const apiError = Object.assign(new Error("Payment Required"), { statusCode: 402 }); const event = mapStreamPart({ type: "error", error: apiError } as any, tracked); expect(event!.type).toBe("error"); expect(event!.errorCode).toBe("credits_exhausted"); expect(event!.error).toContain("out of credits"); }); }); describe("contextToMessages orphaned tool calls", () => { it("strips tool calls with no matching result", () => { const history: ContextMessage[] = [ { role: "user", content: "hello" }, { role: "assistant", content: "thinking...", toolCallsJson: JSON.stringify([ { id: "tc_1", type: "function", function: { name: "search", arguments: "{}" } }, { id: "tc_2", type: "function", function: { name: "fetch", arguments: "{}" } }, ]), }, { role: "tool", content: "result 1", toolCallId: "tc_1", name: "search" }, ]; const messages = contextToMessages(history); const assistant = messages.find((m) => m.role === "assistant" && Array.isArray(m.content)); expect(assistant).toBeDefined(); const toolCalls = (assistant!.content as any[]).filter((p: any) => p.type === "tool-call"); expect(toolCalls).toHaveLength(1); expect(toolCalls[0].toolCallId).toBe("tc_1"); }); it("drops assistant tool message entirely when all results are missing", () => { const history: ContextMessage[] = [ { role: "user", content: "hello" }, { role: "assistant", content: "", toolCallsJson: JSON.stringify([ { id: "tc_orphan", type: "function", function: { name: "search", arguments: "{}" } }, ]), }, ]; const messages = contextToMessages(history); expect(messages).toHaveLength(1); expect(messages[0]!.role).toBe("user"); }); it("keeps text content when all tool calls are orphaned", () => { const history: ContextMessage[] = [ { role: "user", content: "hello" }, { role: "assistant", content: "Let me look that up", toolCallsJson: JSON.stringify([ { id: "tc_orphan", type: "function", function: { name: "search", arguments: "{}" } }, ]), }, ]; const messages = contextToMessages(history); expect(messages).toHaveLength(2); expect(messages[1]!.role).toBe("assistant"); expect(messages[1]!.content).toBe("Let me look that up"); }); it("preserves fully-matched tool calls unchanged", () => { const history: ContextMessage[] = [ { role: "user", content: "hello" }, { role: "assistant", content: "", toolCallsJson: JSON.stringify([ { id: "tc_a", type: "function", function: { name: "search", arguments: "{}" } }, { id: "tc_b", type: "function", function: { name: "fetch", arguments: "{}" } }, ]), }, { role: "tool", content: "result a", toolCallId: "tc_a", name: "search" }, { role: "tool", content: "result b", toolCallId: "tc_b", name: "fetch" }, ]; const messages = contextToMessages(history); const assistant = messages.find((m) => m.role === "assistant" && Array.isArray(m.content)); const toolCalls = (assistant!.content as any[]).filter((p: any) => p.type === "tool-call"); expect(toolCalls).toHaveLength(2); }); });