import { afterEach, describe, expect, it } from "bun:test"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Agent, type BackgroundEventSink } from "../src/agent/agent.ts"; import type { AgentConfig } from "../src/agent/parser.ts"; import type { StreamEvent } from "../src/agent/types.ts"; import { createMockModel, textGenerateResult, textStreamResult, toolCallStreamResult, } from "./helpers/mock-provider.ts"; const testDir = join(tmpdir(), `mozart-wf-sched-${process.pid}-${Date.now()}`); function makeConfig(name = "sched-agent", overrides: Partial = {}): AgentConfig { return { model: "test/model", identity: "A scheduling test agent.", name, ifThen: [], schedule: [], skills: [], sanctums: [], sourcePath: "/tmp/test.soul", ...overrides, }; } function dataDir(name = "sched-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: scheduling", () => { let agent: Agent; afterEach(() => { try { agent?.destroy(); } catch {} }); it("schedule tool creates a persisted schedule via the agent loop", async () => { const model = createMockModel({ streamResponses: [ toolCallStreamResult([{ name: "schedule", arguments: { timing: "every 5 minutes", task: "check health" } }]), textStreamResult("Schedule created."), ], generateResponses: [textGenerateResult("*/5 * * * *")], }); agent = new Agent(makeConfig(), model, dataDir()); await agent.initialize(); const events = await collectEvents(agent.handleMessage("user", "Set up a health check", "conv-s1")); const toolResult = events.find((e) => e.type === "tool_result"); expect(toolResult).toBeDefined(); expect(toolResult!.text).toContain("Created schedule"); expect(toolResult!.text).toContain("*/5 * * * *"); const schedules = agent.getSchedules(); expect(schedules).toHaveLength(1); expect(schedules[0]!.task).toBe("check health"); expect(schedules[0]!.cron).toBe("*/5 * * * *"); }); it("list_schedules shows existing schedules", async () => { const model = createMockModel({ streamResponses: [ toolCallStreamResult([{ name: "list_schedules", arguments: {} }]), textStreamResult("Here are your schedules."), ], }); agent = new Agent(makeConfig(), model, dataDir()); await agent.initialize(); agent.memory.saveSchedule("s1", "0 9 * * *", "daily at 9am", "standup summary"); const events = await collectEvents(agent.handleMessage("user", "Show schedules", "conv-s2")); const result = events.find((e) => e.type === "tool_result"); expect(result).toBeDefined(); expect(result!.text).toContain("s1"); expect(result!.text).toContain("standup summary"); }); it("delete_schedule removes the schedule via agent loop", async () => { const model = createMockModel({ streamResponses: [ toolCallStreamResult([{ name: "delete_schedule", arguments: { schedule_id: "s-del" } }]), textStreamResult("Schedule deleted."), ], }); agent = new Agent(makeConfig(), model, dataDir()); await agent.initialize(); agent.memory.saveSchedule("s-del", "*/10 * * * *", "every 10 min", "ping"); agent.registerScheduleJob("s-del", "*/10 * * * *", "ping"); expect(agent.getSchedules()).toHaveLength(1); const events = await collectEvents(agent.handleMessage("user", "Remove that schedule", "conv-s3")); const result = events.find((e) => e.type === "tool_result"); expect(result).toBeDefined(); expect(result!.text).toContain("Deleted"); expect(agent.getSchedules()).toHaveLength(0); }); it("schedules are reactivated on agent initialize", async () => { const model = createMockModel({}); const name = `sched-react-${Date.now()}`; agent = new Agent(makeConfig(name), model, dataDir(name)); agent.memory.saveSchedule("persist-1", "0 */2 * * *", "every 2 hours", "check metrics"); agent.memory.saveSchedule("persist-2", "0 12 * * *", "noon daily", "lunch reminder"); await agent.initialize(); const schedules = agent.getSchedules(); expect(schedules).toHaveLength(2); expect(schedules.map((s) => s.id).sort()).toEqual(["persist-1", "persist-2"]); }); it("schedule callback fires handleMessage with scheduler as sender", async () => { const model = createMockModel({ streamResponses: [textStreamResult("Health check passed.")], }); agent = new Agent(makeConfig(), model, dataDir()); await agent.initialize(); const bgEvents: Array<{ source: string; event: StreamEvent | null }> = []; const sink: BackgroundEventSink = (source, _convId, event) => { bgEvents.push({ source, event }); }; agent.setBackgroundEventSink(sink); const events = await collectEvents(agent.handleMessage("scheduler", "check health", "sched-conv")); expect(events.some((e) => e.type === "text" && e.text === "Health check passed.")).toBe(true); expect(events.some((e) => e.type === "done")).toBe(true); }); it("SCHEDULE directives in .soul are activated on initialize", async () => { const model = createMockModel({ generateResponses: [textGenerateResult("*/30 * * * *"), textGenerateResult("0 8 * * 1-5")], }); const name = `soul-sched-${Date.now()}`; const config = makeConfig(name, { schedule: [ { timing: "every 30 minutes", task: "check inbox" }, { timing: "weekdays at 8am", task: "morning brief" }, ], }); agent = new Agent(config, model, dataDir(name)); await agent.initialize(); const schedules = agent.getSchedules(); expect(schedules).toHaveLength(2); const ids = schedules.map((s) => s.id).sort(); expect(ids).toContain("soul-every-30-minutes"); expect(ids).toContain("soul-weekdays-at-8am"); }); it("SCHEDULE directives skip if already persisted", async () => { const model = createMockModel({}); const name = `soul-dedup-${Date.now()}`; const config = makeConfig(name, { schedule: [{ timing: "every hour", task: "old task" }], }); agent = new Agent(config, model, dataDir(name)); agent.memory.saveSchedule("soul-every-hour", "0 * * * *", "every hour", "old task"); await agent.initialize(); const schedules = agent.getSchedules(); expect(schedules).toHaveLength(1); expect(schedules[0]!.task).toBe("old task"); expect(model.doGenerateCalls).toHaveLength(0); }); });