import { ONCE_ROUTINE_CRON } from "@rakazo/core"; import { describe, expect, it, vi } from "vitest"; import { builtinAgentTools } from "./builtin-tools.js"; import { cancelScheduleFromTool, createScheduleFromTool, filterBuiltinToolsForRun, filterBuiltinToolsForThread, isOneShotRoutineCron, listSchedulesFromTool, MAX_ACTIVE_ROUTINES_PER_BOT, resolveScheduleOwner, resolveScheduleTiming, SCHEDULE_TOOL_NAMES, withScheduleTargetBot, } from "./schedule-tools.js"; describe("resolveScheduleTiming", () => { it("rejects repeating intervals under one minute", () => { expect(resolveScheduleTiming({ every: 2, unit: "seconds" })).toEqual({ ok: false, error: "minimum interval is 1 minute", }); }); it("accepts every 1 minute", () => { const result = resolveScheduleTiming({ every: 1, unit: "minutes" }); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.cron).toBe("*/1 * * * *"); expect(result.oneShot).toBe(false); expect(result.nextRunAt.getTime()).toBeGreaterThan(Date.now()); }); it("accepts cron every minute", () => { const result = resolveScheduleTiming({ cron: "*/1 * * * *" }); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.cron).toBe("*/1 * * * *"); expect(result.oneShot).toBe(false); }); it("creates one-shot schedules with @once cron", () => { const result = resolveScheduleTiming({ delayMinutes: 10 }); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.cron).toBe(ONCE_ROUTINE_CRON); expect(result.oneShot).toBe(true); expect(result.nextRunAt.getTime()).toBeGreaterThan(Date.now() + 9 * 60_000); }); it("allows sub-minute one-shot delays via delaySeconds", () => { const result = resolveScheduleTiming({ delaySeconds: 30 }); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.cron).toBe(ONCE_ROUTINE_CRON); expect(result.nextRunAt.getTime()).toBeGreaterThan(Date.now()); expect(result.nextRunAt.getTime()).toBeLessThanOrEqual(Date.now() + 31_000); }); it("rejects one-shot schedules in the past", () => { expect(resolveScheduleTiming({ runAt: new Date(Date.now() - 60_000).toISOString() })).toEqual({ ok: false, error: "One-shot schedules must run in the future.", }); }); it("rejects fractional repeat intervals", () => { expect(resolveScheduleTiming({ every: 1.5, unit: "minutes" })).toEqual({ ok: false, error: "every must be a positive whole number.", }); }); it("rejects malformed cron expressions", () => { expect(resolveScheduleTiming({ cron: "*/5 *" })).toEqual({ ok: false, error: "cron must be a 5-field cron expression.", }); expect(resolveScheduleTiming({ cron: "foo * * * *" })).toEqual({ ok: false, error: "cron must be a valid 5-field cron expression.", }); }); it("rejects mixing repeat and one-shot fields", () => { expect(resolveScheduleTiming({ cron: "0 9 * * *", delayMinutes: 5 })).toEqual({ ok: false, error: "Provide either a repeating schedule or a one-shot time, not both.", }); }); }); describe("filterBuiltinToolsForThread", () => { const tools = [ { name: "handoff_to_bot" }, { name: "message_bot" }, { name: "schedule_create" }, { name: "schedule_list" }, { name: "schedule_cancel" }, { name: "remember" }, ]; it("keeps handoff and schedule tools in groups", () => { expect(filterBuiltinToolsForThread(tools, "group-1").map((tool) => tool.name)).toEqual([ "handoff_to_bot", "schedule_create", "schedule_list", "schedule_cancel", "remember", ]); expect(filterBuiltinToolsForThread(tools, null).map((tool) => tool.name)).toEqual([ "message_bot", "schedule_create", "schedule_list", "schedule_cancel", "remember", ]); }); it("hides schedule creation from routine runs without removing schedule management", () => { expect(filterBuiltinToolsForRun(tools, "routine").map((tool) => tool.name)).toEqual([ "handoff_to_bot", "message_bot", "schedule_list", "schedule_cancel", "remember", ]); expect(filterBuiltinToolsForRun(tools, "user").map((tool) => tool.name)).toContain( "schedule_create", ); }); it("composes thread then run filters without dropping group schedule tools", () => { const groupTools = filterBuiltinToolsForRun( filterBuiltinToolsForThread(tools, "group-1"), "routine", ).map((tool) => tool.name); expect(groupTools).toEqual(["handoff_to_bot", "schedule_list", "schedule_cancel", "remember"]); expect( filterBuiltinToolsForRun(filterBuiltinToolsForThread(tools, "group-1"), "user").map( (tool) => tool.name, ), ).toContain("schedule_create"); }); it("covers every schedule tool name", () => { for (const name of SCHEDULE_TOOL_NAMES) { expect(filterBuiltinToolsForThread([{ name }, { name: "remember" }], "group-1")).toEqual([ { name }, { name: "remember" }, ]); } }); }); describe("isOneShotRoutineCron", () => { it("detects the one-shot sentinel", () => { expect(isOneShotRoutineCron(ONCE_ROUTINE_CRON)).toBe(true); expect(isOneShotRoutineCron("0 9 * * *")).toBe(false); }); }); describe("schedule tool persistence", () => { it("creates routines with routine.created and enqueues wakeup", async () => { const append = vi.fn(async () => undefined); const enqueue = vi.fn(async () => undefined); const create = vi.fn(async (args: { data: Record }) => ({ id: "routine-1", ...args.data, nextRunAt: args.data.nextRunAt, })); const deps = { prisma: { routine: { create, findFirst: async () => null, count: async () => 0 }, userProfile: { findUnique: async () => null }, }, events: { append }, jobs: { enqueue, cancel: vi.fn(async () => undefined) }, } as unknown as Parameters[0]; const result = await createScheduleFromTool(deps, { spaceId: "ws-1", botId: "bot-1", userId: "user-1", threadId: "thread-1", name: "Morning joke", prompt: "Tell a joke", schedule: { every: 1, unit: "minutes" }, }); expect(result).toMatchObject({ ok: true, routineId: "routine-1", cron: "*/1 * * * *" }); expect(create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ spaceId: "ws-1", userId: "user-1", threadId: "thread-1", crons: ["*/1 * * * *"], active: true, }), }), ); expect(append).toHaveBeenCalledWith( expect.objectContaining({ type: "routine.created", payload: { name: "Morning joke" } }), ); expect(enqueue).toHaveBeenCalledOnce(); }); it("refuses another schedule once the bot has the maximum active", async () => { const create = vi.fn(); const deps = { prisma: { routine: { create, findFirst: async () => null, count: async () => MAX_ACTIVE_ROUTINES_PER_BOT, }, userProfile: { findUnique: async () => null }, }, events: { append: vi.fn(async () => undefined) }, jobs: { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) }, } as unknown as Parameters[0]; const result = await createScheduleFromTool(deps, { spaceId: "ws-1", botId: "bot-1", userId: "user-1", threadId: "thread-1", name: "One more", prompt: "Do it", schedule: { cron: "0 9 * * *" }, }); expect(result).toEqual({ error: `This bot already has ${MAX_ACTIVE_ROUTINES_PER_BOT} active schedules. Cancel one before adding another.`, }); expect(create).not.toHaveBeenCalled(); }); it("defaults the routine time zone to the user's and lets an explicit zone win", async () => { const create = vi.fn(async (args: { data: Record }) => ({ id: "routine-1", ...args.data, })); const findUnique = vi.fn(async () => ({ timeZone: "America/New_York" })); const deps = { prisma: { routine: { create, findFirst: async () => null, count: async () => 0 }, userProfile: { findUnique }, }, events: { append: vi.fn(async () => undefined) }, jobs: { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) }, } as unknown as Parameters[0]; const input = { spaceId: "ws-1", botId: "bot-1", userId: "user-1", threadId: "thread-1", name: "Standup", prompt: "Post the standup", schedule: { cron: "0 9 * * *" }, }; await createScheduleFromTool(deps, input); expect(findUnique).toHaveBeenCalledWith({ where: { userId: "user-1" }, select: { timeZone: true }, }); expect(create).toHaveBeenLastCalledWith( expect.objectContaining({ data: expect.objectContaining({ timezone: "America/New_York" }), }), ); const firstRun = (create.mock.calls[0]![0].data.nextRunAt as Date).toISOString(); expect(["13:00", "14:00"]).toContain(firstRun.slice(11, 16)); await createScheduleFromTool(deps, { ...input, timezone: "Asia/Tokyo" }); expect(findUnique).toHaveBeenCalledOnce(); expect(create).toHaveBeenLastCalledWith( expect.objectContaining({ data: expect.objectContaining({ timezone: "Asia/Tokyo" }) }), ); }); it("falls back to UTC when the user has no known time zone", async () => { const create = vi.fn(async (args: { data: Record }) => ({ id: "routine-1", ...args.data, })); const deps = { prisma: { routine: { create, findFirst: async () => null, count: async () => 0 }, userProfile: { findUnique: vi.fn(async () => ({ timeZone: "Mars/Olympus" })) }, }, events: { append: vi.fn(async () => undefined) }, jobs: { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) }, } as unknown as Parameters[0]; await createScheduleFromTool(deps, { spaceId: "ws-1", botId: "bot-1", userId: "user-1", threadId: "thread-1", name: "Standup", prompt: "Post the standup", schedule: { cron: "0 9 * * *" }, }); expect(create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ timezone: "UTC" }) }), ); }); it("keeps the routine when event append fails after enqueue", async () => { const cancel = vi.fn(async () => undefined); const remove = vi.fn(async () => undefined); const deps = { prisma: { userProfile: { findUnique: async () => null }, routine: { findFirst: async () => null, count: async () => 0, create: vi.fn(async () => ({ id: "routine-1", name: "Morning joke", crons: ["*/1 * * * *"], nextRunAt: new Date(), })), delete: remove, }, }, events: { append: vi.fn(async () => { throw new Error("event failed"); }), }, jobs: { enqueue: vi.fn(async () => undefined), cancel }, } as unknown as Parameters[0]; const result = await createScheduleFromTool(deps, { spaceId: "ws-1", botId: "bot-1", userId: "user-1", threadId: "thread-1", name: "Morning joke", prompt: "Tell a joke", schedule: { every: 1, unit: "minutes" }, }); expect(result).toMatchObject({ ok: true, routineId: "routine-1" }); expect(cancel).not.toHaveBeenCalled(); expect(remove).not.toHaveBeenCalled(); }); it("rolls back routine when enqueue fails", async () => { const remove = vi.fn(async () => undefined); const deps = { prisma: { userProfile: { findUnique: async () => null }, routine: { findFirst: async () => null, count: async () => 0, create: vi.fn(async () => ({ id: "routine-1", name: "Morning joke", crons: ["*/1 * * * *"], nextRunAt: new Date(), })), delete: remove, }, }, events: { append: vi.fn(async () => undefined) }, jobs: { enqueue: vi.fn(async () => { throw new Error("enqueue failed"); }), cancel: vi.fn(async () => undefined), }, } as unknown as Parameters[0]; const result = await createScheduleFromTool(deps, { spaceId: "ws-1", botId: "bot-1", userId: "user-1", threadId: "thread-1", name: "Morning joke", prompt: "Tell a joke", schedule: { every: 1, unit: "minutes" }, }); expect(result).toEqual({ error: "Could not schedule the reminder. Try again." }); expect(remove).toHaveBeenCalledWith({ where: { id: "routine-1" } }); }); it("deactivates when enqueue fails and delete also fails", async () => { const update = vi.fn(async () => undefined); const deps = { prisma: { userProfile: { findUnique: async () => null }, routine: { findFirst: async () => null, count: async () => 0, create: vi.fn(async () => ({ id: "routine-1", name: "Morning joke", crons: ["*/1 * * * *"], nextRunAt: new Date(), })), delete: vi.fn(async () => { throw new Error("delete failed"); }), update, }, }, events: { append: vi.fn(async () => undefined) }, jobs: { enqueue: vi.fn(async () => { throw new Error("enqueue failed"); }), cancel: vi.fn(async () => undefined), }, } as unknown as Parameters[0]; const result = await createScheduleFromTool(deps, { spaceId: "ws-1", botId: "bot-1", userId: "user-1", threadId: "thread-1", name: "Morning joke", prompt: "Tell a joke", schedule: { every: 1, unit: "minutes" }, }); expect(result).toEqual({ error: "Could not schedule the reminder. Try again." }); expect(update).toHaveBeenCalledWith({ where: { id: "routine-1" }, data: { active: false, nextRunAt: null }, }); }); it("propagates when enqueue rollback cannot deactivate", async () => { const deps = { prisma: { userProfile: { findUnique: async () => null }, routine: { findFirst: async () => null, count: async () => 0, create: vi.fn(async () => ({ id: "routine-1", name: "Morning joke", crons: ["*/1 * * * *"], nextRunAt: new Date(), })), delete: vi.fn(async () => { throw new Error("delete failed"); }), update: vi.fn(async () => { throw new Error("deactivate failed"); }), }, }, events: { append: vi.fn(async () => undefined) }, jobs: { enqueue: vi.fn(async () => { throw new Error("enqueue failed"); }), cancel: vi.fn(async () => undefined), }, } as unknown as Parameters[0]; await expect( createScheduleFromTool(deps, { spaceId: "ws-1", botId: "bot-1", userId: "user-1", threadId: "thread-1", name: "Morning joke", prompt: "Tell a joke", schedule: { every: 1, unit: "minutes" }, }), ).rejects.toThrow("deactivate failed"); }); it("scopes group list and cancel to the current thread without narrowing DMs", async () => { const findMany = vi.fn(async () => []); const deps = { prisma: { routine: { findMany, delete: vi.fn(async () => undefined) }, }, events: { append: vi.fn(async () => undefined) }, jobs: { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) }, } as unknown as Parameters[0]; const scheduledForOthers = { userId: "user-1", createdByBotId: "bot-1" }; await listSchedulesFromTool(deps, { spaceId: "ws-1", botId: "bot-1", userId: "user-1", threadId: "group-thread-1", }); expect(findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { OR: [ { spaceId: "ws-1", botId: "bot-1", userId: "user-1", threadId: "group-thread-1" }, scheduledForOthers, ], }, }), ); const cancelled = await cancelScheduleFromTool(deps, { spaceId: "ws-1", botId: "bot-1", userId: "user-1", threadId: "group-thread-1", routineId: "routine-1", }); expect(cancelled).toEqual({ error: "Schedule not found." }); expect(findMany).toHaveBeenLastCalledWith( expect.objectContaining({ where: { OR: [ { spaceId: "ws-1", botId: "bot-1", userId: "user-1", threadId: "group-thread-1" }, scheduledForOthers, ], id: "routine-1", }, }), ); findMany.mockClear(); await listSchedulesFromTool(deps, { spaceId: "ws-1", botId: "bot-1", userId: "user-1" }); expect(findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { OR: [{ spaceId: "ws-1", botId: "bot-1", userId: "user-1" }, scheduledForOthers], }, }), ); findMany.mockClear(); await cancelScheduleFromTool(deps, { spaceId: "ws-1", botId: "bot-1", userId: "user-1", routineId: "routine-1", }); expect(findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { OR: [{ spaceId: "ws-1", botId: "bot-1", userId: "user-1" }, scheduledForOthers], id: "routine-1", }, }), ); }); }); describe("scheduling on behalf of another bot", () => { const main = { botId: "main-1", kind: "main", spaceId: "home", userId: "user-1", threadId: "main-thread", }; const bots = [ { id: "main-1", name: "Chief", spaceId: "home", thread: { id: "chief-dm" } }, { id: "writer-1", name: "Writer", spaceId: "home", thread: { id: "writer-dm" } }, { id: "scout-1", name: "Scout", spaceId: "work", thread: { id: "scout-dm" } }, { id: "scout-2", name: "Scout", spaceId: "lab", thread: { id: "scout-lab-dm" } }, { id: "atlas-1", name: "Atlas", spaceId: "work", thread: { id: "atlas-dm" } }, { id: "atlas-2", name: "atlas", spaceId: "home", thread: { id: "atlas-home-dm" } }, ]; function prismaWith(mainConversationThreadId: string | null) { return { bot: { findMany: vi.fn(async () => bots) }, chatGroup: { findFirst: vi.fn(async () => mainConversationThreadId ? { thread: { id: mainConversationThreadId } } : null, ), }, } as unknown as Parameters[0]; } it("keeps a schedule with its creator when no bot is named", async () => { const prisma = prismaWith(null); await expect(resolveScheduleOwner(prisma, main, undefined)).resolves.toEqual({ spaceId: "home", botId: "main-1", userId: "user-1", threadId: "main-thread", }); await expect(resolveScheduleOwner(prisma, main, "Chief")).resolves.toMatchObject({ botId: "main-1", threadId: "main-thread", }); expect(prisma.chatGroup.findFirst).not.toHaveBeenCalled(); }); it("lets only the main bot schedule for another bot", async () => { const prisma = prismaWith(null); await expect( resolveScheduleOwner(prisma, { ...main, kind: "specialist" }, "Writer"), ).resolves.toEqual({ error: "Only the main agent can schedule for another bot." }); expect(prisma.bot.findMany).not.toHaveBeenCalled(); }); it("wakes in the target's 1:1 thread in its own space unless it is in the main conversation", async () => { const prisma = prismaWith(null); await expect(resolveScheduleOwner(prisma, main, "atlas-1")).resolves.toEqual({ spaceId: "work", botId: "atlas-1", userId: "user-1", threadId: "atlas-dm", createdByBotId: "main-1", botName: "Atlas", }); expect(prisma.bot.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { userId: "user-1", archivedAt: null, space: { deletingAt: null, memberships: { some: { userId: "user-1" } } }, }, }), ); expect(prisma.chatGroup.findFirst).toHaveBeenCalledWith( expect.objectContaining({ where: { userId: "user-1", kind: "main", archivedAt: null, spaceId: "work", members: { some: { botId: "atlas-1" } }, }, }), ); }); it("wakes in the main conversation when the target is a member", async () => { await expect(resolveScheduleOwner(prismaWith("main-thread"), main, "Writer")).resolves.toEqual({ spaceId: "home", botId: "writer-1", userId: "user-1", threadId: "main-thread", createdByBotId: "main-1", botName: "Writer", }); }); it("resolves names exactly first, then case-insensitively, preferring the creator's space", async () => { const prisma = prismaWith(null); await expect(resolveScheduleOwner(prisma, main, "Atlas")).resolves.toMatchObject({ botId: "atlas-1", }); await expect(resolveScheduleOwner(prisma, main, "ATLAS")).resolves.toMatchObject({ botId: "atlas-2", }); await expect(resolveScheduleOwner(prisma, main, "Scout")).resolves.toEqual({ error: "Several bots are named Scout. Use the bot id.", }); await expect(resolveScheduleOwner(prisma, main, "scout-2")).resolves.toMatchObject({ botId: "scout-2", spaceId: "lab", }); await expect(resolveScheduleOwner(prisma, main, "Nobody")).resolves.toEqual({ error: "No bot named Nobody.", }); }); it("records the creating bot and names the target in the result", async () => { const create = vi.fn(async (args: { data: Record }) => ({ id: "routine-9", ...args.data, })); const append = vi.fn(async () => undefined); const deps = { // No profile time zone: the routine falls back to UTC. prisma: { routine: { create, findFirst: async () => null, count: async () => 0 }, userProfile: { findUnique: async () => null }, }, events: { append }, jobs: { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) }, } as unknown as Parameters[0]; const result = await createScheduleFromTool(deps, { spaceId: "work", botId: "atlas-1", userId: "user-1", threadId: "atlas-dm", createdByBotId: "main-1", botName: "Atlas", name: "Weekly digest", prompt: "Summarize the week", schedule: { cron: "0 9 * * 1" }, }); expect(result).toMatchObject({ ok: true, routineId: "routine-9", bot: "Atlas" }); expect(create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ spaceId: "work", botId: "atlas-1", threadId: "atlas-dm", createdByBotId: "main-1", }), }), ); expect(append).toHaveBeenCalledWith( expect.objectContaining({ spaceId: "work", threadId: "atlas-dm", botId: "atlas-1" }), ); }); it("lists routines scheduled for other bots with their bot's name", async () => { const row = (id: string, botId: string, botName: string) => ({ id, botId, name: `${id} name`, prompt: "p", crons: ["0 9 * * *"], active: true, nextRunAt: null, bot: { name: botName }, }); const deps = { prisma: { routine: { findMany: vi.fn(async () => [ row("own", "main-1", "Chief"), row("for-atlas", "atlas-1", "Atlas"), ]), }, }, } as unknown as Parameters[0]; const listed = await listSchedulesFromTool(deps, { spaceId: "home", botId: "main-1", userId: "user-1", }); expect(listed.routines[0]).not.toHaveProperty("bot"); expect(listed.routines[1]).toMatchObject({ routineId: "for-atlas", bot: "Atlas" }); }); it("refuses to cancel by a name that matches several routines", async () => { const remove = vi.fn(async () => undefined); const deps = { prisma: { routine: { findMany: vi.fn(async () => [ { id: "a", name: "Digest" }, { id: "b", name: "Digest" }, ]), delete: remove, }, }, events: { append: vi.fn(async () => undefined) }, jobs: { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) }, } as unknown as Parameters[0]; await expect( cancelScheduleFromTool(deps, { spaceId: "home", botId: "main-1", userId: "user-1", name: "Digest", }), ).resolves.toEqual({ error: "Several schedules have that name. Cancel by routineId." }); expect(remove).not.toHaveBeenCalled(); }); it("adds the bot option to schedule_create without touching the original definition", () => { const tool = { name: "schedule_create", description: "Create a schedule", inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] }, }; const extended = withScheduleTargetBot(tool); expect(Object.keys(extended.inputSchema.properties as object)).toEqual(["name", "bot"]); expect(extended.inputSchema.required).toEqual(["name"]); expect(Object.keys(tool.inputSchema.properties)).toEqual(["name"]); }); it("keeps schedule_create for later or repeated work, not work wanted now", () => { const tool = builtinAgentTools.find((candidate) => candidate.name === "schedule_create"); expect(tool?.description).toContain("Never use it for work the user wants now"); }); });