A live check showed the agent answering a business-idea question by creating a schedule that would run the analysis later. The schedule_create description now says never to use it for work the user wants now, however long.
791 lines
26 KiB
TypeScript
791 lines
26 KiB
TypeScript
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<string, unknown> }) => ({
|
|
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<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("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<typeof createScheduleFromTool>[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<string, unknown> }) => ({
|
|
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<typeof createScheduleFromTool>[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<string, unknown> }) => ({
|
|
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<typeof createScheduleFromTool>[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<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: {
|
|
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<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: {
|
|
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<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: {
|
|
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<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 = {
|
|
// 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<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"]);
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|