import { afterEach, describe, expect, it } from "bun:test"; import { existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Agent } from "../src/agent/agent.ts"; import type { AgentConfig } from "../src/agent/parser.ts"; import type { StreamEvent } from "../src/agent/types.ts"; import { createMockModel, textStreamResult, toolCallStreamResult } from "./helpers/mock-provider.ts"; const testDir = join(tmpdir(), `mozart-wf-tools-${process.pid}-${Date.now()}`); function makeConfig(name = "test-agent"): AgentConfig { return { model: "test/model", identity: "A test agent.", name, ifThen: [], schedule: [], skills: [], sanctums: [], sourcePath: "/tmp/test.soul", }; } function dataDir(name = "test-agent"): string { return join(testDir, name); } async function collectEvents(gen: AsyncGenerator): Promise { const events: StreamEvent[] = []; for await (const e of gen) events.push(e); return events; } describe("workflow: tool execution", () => { let agent: Agent; afterEach(() => { try { agent.destroy(); } catch {} }); it("memory round-trip: save then search", async () => { const model = createMockModel({ streamResponses: [ toolCallStreamResult([{ name: "save_to_memory", arguments: { content: "User prefers dark mode" } }]), textStreamResult("Saved your preference."), ], }); const dir = dataDir(); agent = new Agent(makeConfig(), model, dir); await agent.initialize(); const events = await collectEvents(agent.handleMessage("user", "Remember I like dark mode", "conv-1")); const toolUse = events.find((e) => e.type === "tool_use"); expect(toolUse).toBeDefined(); expect(toolUse!.toolUse!.name).toBe("save_to_memory"); const toolResult = events.find((e) => e.type === "tool_result"); expect(toolResult).toBeDefined(); expect(toolResult!.text).toContain("Saved to memory"); expect(events.some((e) => e.type === "done")).toBe(true); const model2 = createMockModel({ streamResponses: [ toolCallStreamResult([{ name: "search_memory", arguments: { query: "dark mode" } }]), textStreamResult("You prefer dark mode."), ], }); agent = new Agent(makeConfig(), model2, dir); await agent.initialize(); const events2 = await collectEvents(agent.handleMessage("user", "What theme do I like?", "conv-1")); const searchResult = events2.find((e) => e.type === "tool_result"); expect(searchResult).toBeDefined(); expect(searchResult!.text).toContain("dark mode"); }); it("write_file then read_file round-trip", async () => { const filePath = join(testDir, "test-output", "hello.txt"); const model = createMockModel({ streamResponses: [ toolCallStreamResult([{ name: "write_file", arguments: { path: filePath, content: "Hello from agent!" } }]), textStreamResult("File written."), ], }); agent = new Agent(makeConfig(), model, dataDir()); await agent.initialize(); const events = await collectEvents(agent.handleMessage("user", "Write a file", "conv-2")); const writeResult = events.find((e) => e.type === "tool_result"); expect(writeResult).toBeDefined(); expect(writeResult!.text).toContain("Written"); expect(existsSync(filePath)).toBe(true); const model2 = createMockModel({ streamResponses: [ toolCallStreamResult([{ name: "read_file", arguments: { path: filePath } }]), textStreamResult("The file contains: Hello from agent!"), ], }); agent = new Agent(makeConfig(), model2, dataDir()); await agent.initialize(); const events2 = await collectEvents(agent.handleMessage("user", "Read it back", "conv-2")); const readResult = events2.find((e) => e.type === "tool_result"); expect(readResult).toBeDefined(); expect(readResult!.text).toContain("Hello from agent!"); }); it("shell tool executes a command and returns output", async () => { const model = createMockModel({ streamResponses: [ toolCallStreamResult([{ name: "shell", arguments: { command: "echo hello-world" } }]), textStreamResult("The output was hello-world."), ], }); agent = new Agent(makeConfig(), model, dataDir()); await agent.initialize(); const events = await collectEvents(agent.handleMessage("user", "Run echo", "conv-3")); const toolUse = events.find((e) => e.type === "tool_use"); expect(toolUse!.toolUse!.name).toBe("shell"); const hasShellOutput = events.some( (e) => (e.type === "tool_delta" || e.type === "tool_result") && e.text?.includes("hello-world"), ); expect(hasShellOutput).toBe(true); expect(events.some((e) => e.type === "done")).toBe(true); }); it("multiple tools in a single turn", async () => { const model = createMockModel({ streamResponses: [ toolCallStreamResult([ { name: "save_to_memory", arguments: { content: "Fact A" } }, { name: "save_to_memory", arguments: { content: "Fact B" } }, ]), textStreamResult("Both facts saved."), ], }); agent = new Agent(makeConfig(), model, dataDir()); await agent.initialize(); const events = await collectEvents(agent.handleMessage("user", "Save two facts", "conv-4")); const toolUses = events.filter((e) => e.type === "tool_use"); expect(toolUses).toHaveLength(2); const toolResults = events.filter((e) => e.type === "tool_result"); expect(toolResults).toHaveLength(2); expect(model.doStreamCalls).toHaveLength(2); }); it("text-only response without tool calls", async () => { const model = createMockModel({ streamResponses: [textStreamResult("Just a plain answer.")], }); agent = new Agent(makeConfig(), model, dataDir()); await agent.initialize(); const events = await collectEvents(agent.handleMessage("user", "Hi", "conv-7")); expect(events.filter((e) => e.type === "text")).toHaveLength(1); expect(events.find((e) => e.type === "text")!.text).toBe("Just a plain answer."); expect(events.some((e) => e.type === "done")).toBe(true); expect(events.filter((e) => e.type === "tool_use")).toHaveLength(0); expect(model.doStreamCalls).toHaveLength(1); }); it("persists conversation history across messages", async () => { const model = createMockModel({ streamResponses: [textStreamResult("Hello!"), textStreamResult("World!")], }); agent = new Agent(makeConfig(), model, dataDir()); await agent.initialize(); await collectEvents(agent.handleMessage("user", "First message", "conv-8")); await collectEvents(agent.handleMessage("user", "Second message", "conv-8")); const secondCallMessages = model.doStreamCalls[1]!.prompt; const userMessages = secondCallMessages.filter((m: { role: string }) => m.role === "user"); expect(userMessages.length).toBeGreaterThanOrEqual(2); }); });