Every bot saw every builtin tool, and nothing outside the runtime's own declaration kept a run to the tools it was given: the executor ran any tool a runtime asked for, and the Pi runtime exposed all builtins whenever a caller passed an empty tool list, which the safety checker, the history summarizer and the team chat judge do on purpose. A specialist could not be kept to the tools of its lane, bots had no capability tags, and the main bot could only schedule routines for itself. - A bot can carry a builtin tool allowlist. Its runs see only the allowlisted builtins, and an empty allowlist means none. MAIN_ONLY_BUILTIN_TOOLS (empty until the user profile and delegation tools land) never reach other bots. applyTool refuses any tool the run did not expose, and an empty tool list now means no tools for the Pi runtime and its subagents. - A bot spawned by a restricted bot inherits the restriction and a duplicate keeps it, so neither path widens a bot's tools. - bots.create and bots.update accept tags, restrictBuiltinTools and allowedBuiltinTools; the router rejects names that are not builtin tools. bots.builtinTools lists the names a bot's allowlist can use. - Bot settings get a Tags field and a switch with a tool checklist under Advanced. - The main bot's schedule_create takes an optional bot: one of the user's bots in any space the user still belongs to. The routine belongs to that bot in its space and wakes in the main conversation when the bot is a member there, otherwise in its 1:1 thread. Routines record the bot that scheduled them (additive migration), so schedule_list and schedule_cancel also cover exactly the routines a bot scheduled for others. Cancelling by a name that matches several routines is refused. Tested with unit tests for the per-bot filter, the applyTool guard, the empty tool list, and on-behalf resolution, creation, listing and cancelling; with Postgres journeys for on-behalf routines waking across spaces and in the main conversation and for allowlist enforcement through the scripted runtime; and with a web E2E for the settings section. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
663 lines
21 KiB
TypeScript
663 lines
21 KiB
TypeScript
import { ONCE_ROUTINE_CRON } from "@rakazo/core";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
cancelScheduleFromTool,
|
|
createScheduleFromTool,
|
|
filterBuiltinToolsForRun,
|
|
filterBuiltinToolsForThread,
|
|
isOneShotRoutineCron,
|
|
listSchedulesFromTool,
|
|
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<string, unknown> }) => ({
|
|
id: "routine-1",
|
|
...args.data,
|
|
nextRunAt: args.data.nextRunAt,
|
|
}));
|
|
const deps = {
|
|
prisma: { routine: { create } },
|
|
events: { append },
|
|
jobs: { enqueue, cancel: vi.fn(async () => undefined) },
|
|
} as unknown as Parameters<typeof createScheduleFromTool>[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("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: {
|
|
routine: {
|
|
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<typeof createScheduleFromTool>[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: {
|
|
routine: {
|
|
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<typeof createScheduleFromTool>[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: {
|
|
routine: {
|
|
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<typeof createScheduleFromTool>[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: {
|
|
routine: {
|
|
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<typeof createScheduleFromTool>[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<typeof cancelScheduleFromTool>[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<typeof resolveScheduleOwner>[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<string, unknown> }) => ({
|
|
id: "routine-9",
|
|
...args.data,
|
|
}));
|
|
const append = vi.fn(async () => undefined);
|
|
const deps = {
|
|
prisma: { routine: { create } },
|
|
events: { append },
|
|
jobs: { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) },
|
|
} as unknown as Parameters<typeof createScheduleFromTool>[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<typeof listSchedulesFromTool>[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<typeof cancelScheduleFromTool>[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"]);
|
|
});
|
|
});
|