Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions packages/junior-scheduler/src/schedule-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,30 @@ function validateRecurringFrequencyLimit(input: { recurrence?: unknown }) {
}
}

function validateCreateScheduleKind(input: {
recurrence?: unknown;
schedule_kind?: unknown;
}) {
if (input.schedule_kind === undefined) {
throwToolInputError("Provide schedule_kind as one_off or recurring.");
}
if (
input.schedule_kind !== "one_off" &&
input.schedule_kind !== "recurring"
) {
throwToolInputError("schedule_kind must be one_off or recurring.");
}
if (input.schedule_kind === "one_off" && input.recurrence !== undefined) {
throwToolInputError("Omit recurrence when schedule_kind is one_off.");
}
if (
input.schedule_kind === "recurring" &&
(input.recurrence === undefined || input.recurrence === null)
) {
throwToolInputError("Provide recurrence when schedule_kind is recurring.");
}
}

function shouldRebuildRecurrence(input: {
next_run_at?: string;
recurrence?: unknown;
Expand Down Expand Up @@ -343,11 +367,18 @@ export function createSlackScheduleCreateTaskTool(
) {
return tool({
description:
"Create a future or recurring Junior task in the active Slack conversation. Use only when the user explicitly asks Junior to do work later or on a recurring cadence. Only manage tasks for the active Slack DM or channel; never target threads, other channels, or another user's DM. When the task, schedule, and destination are clear, create it without asking for confirmation; ask only when one of those is ambiguous.",
"Create a one-time or recurring Junior task in the active Slack conversation. For one-time reminders or one-time scheduled work, omit recurrence entirely; never choose a default recurrence. Use only when the user explicitly asks Junior to do work later or on a recurring cadence. Only manage tasks for the active Slack DM or channel; never target threads, other channels, or another user's DM. When the task, schedule, and destination are clear, create it without asking for confirmation; ask only when one of those is ambiguous.",
executionMode: "sequential",
inputSchema: Type.Object({
task: Type.String({ minLength: 1, maxLength: 4000 }),
schedule: Type.String({ minLength: 1, maxLength: 300 }),
schedule_kind: Type.Union(
[Type.Literal("one_off"), Type.Literal("recurring")],
{
description:
"Required schedule classification. Use one_off for one-time reminders or one-time scheduled work. Use recurring only when the user explicitly asks for a repeating schedule.",
},
),
timezone: Type.Optional(
Type.String({
minLength: 1,
Expand All @@ -373,7 +404,7 @@ export function createSlackScheduleCreateTaskTool(
],
{
description:
"Provide only for explicitly repeating schedules; omit for one-time requests like 'in 1 minute', 'tomorrow', or a specific date. Recurring tasks run at most once per day: use daily, weekly, monthly, or yearly only.",
"Required when schedule_kind is recurring. Omit when schedule_kind is one_off. Recurring tasks run at most once per day: use daily, weekly, monthly, or yearly only.",
},
),
),
Expand All @@ -384,6 +415,7 @@ export function createSlackScheduleCreateTaskTool(

const nowMs = Date.now();
const timezone = input.timezone ?? getDefaultScheduleTimezone();
validateCreateScheduleKind(input);
validateRecurringFrequencyLimit(input);
if (!isValidTimeZone(timezone)) {
throwToolInputError("timezone must be a valid IANA time zone.");
Expand Down
68 changes: 68 additions & 0 deletions packages/junior/tests/integration/slack-schedule-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ async function createTask(
return await executeTool(tool, {
task: "Weekly issue digest: Summarize open scheduler issues and post a concise summary.",
schedule: "Every Monday at 9am",
schedule_kind: "recurring",
timezone: "America/Los_Angeles",
next_run_at: "2026-05-25T16:00:00.000Z",
recurrence: "weekly",
Expand All @@ -160,6 +161,27 @@ describe("Slack schedule tools", () => {
await disconnectStateAdapter();
});

it("exposes schedule kind as a required create schema field", async () => {
const schema = createSlackScheduleCreateTaskTool(createContext())
.inputSchema as {
properties?: Record<
string,
{
anyOf?: Array<{ const?: unknown }>;
oneOf?: Array<{ const?: unknown }>;
}
>;
required?: string[];
};
const scheduleKind = schema.properties?.schedule_kind;
const options = (scheduleKind?.anyOf ?? scheduleKind?.oneOf ?? [])
.map((option) => option.const)
.sort();

expect(schema.required).toContain("schedule_kind");
expect(options).toEqual(["one_off", "recurring"]);
});

it("creates and lists tasks only for the active Slack conversation", async () => {
const created = await createTask();
expect(created).toMatchObject({
Expand Down Expand Up @@ -216,6 +238,7 @@ describe("Slack schedule tools", () => {
{
task: "Weekly issue digest: Summarize open scheduler issues and post a concise summary.",
schedule: "Every Monday at 9am",
schedule_kind: "recurring",
timezone: "America/Los_Angeles",
next_run_at: "2026-05-25T16:00:00.000Z",
recurrence: "weekly",
Expand Down Expand Up @@ -290,6 +313,7 @@ describe("Slack schedule tools", () => {
{
task: "Reminder: Remind David to wash his hands.",
schedule: "In 1 minute",
schedule_kind: "one_off",
next_run_at: "2026-05-27T00:25:23.000Z",
},
);
Expand Down Expand Up @@ -414,6 +438,7 @@ describe("Slack schedule tools", () => {
{
task: "Wash hands reminder: Remind David to wash his hands.",
schedule: "In 1 minute",
schedule_kind: "one_off",
next_run_at: "2026-05-27T00:25:23.000Z",
},
);
Expand Down Expand Up @@ -460,6 +485,7 @@ describe("Slack schedule tools", () => {
{
task: "Drink water reminder: Remind David to drink water.",
schedule: "In 1 minute",
schedule_kind: "one_off",
next_run_at: "2026-05-27T00:25:23.000Z",
},
);
Expand Down Expand Up @@ -497,6 +523,7 @@ describe("Slack schedule tools", () => {
{
task: "Remind Greg to drink water.",
schedule: "In 1 minute",
schedule_kind: "one_off",
next_run_at: "2026-05-28T02:18:48.005Z",
},
);
Expand Down Expand Up @@ -560,6 +587,42 @@ describe("Slack schedule tools", () => {
).resolves.toEqual([]);
});

it("rejects create calls that omit schedule kind", async () => {
await expect(
createTask(createContext(), {
schedule_kind: undefined,
}),
).rejects.toThrow("Provide schedule_kind as one_off or recurring.");
await expect(
schedulerStore().listTasksForTeam(TEST_TEAM_ID),
).resolves.toEqual([]);
});

it("rejects one-off create calls that include recurrence", async () => {
await expect(
createTask(createContext(), {
schedule: "In 30 seconds",
schedule_kind: "one_off",
recurrence: "daily",
}),
).rejects.toThrow("Omit recurrence when schedule_kind is one_off.");
await expect(
schedulerStore().listTasksForTeam(TEST_TEAM_ID),
).resolves.toEqual([]);
});

it("rejects recurring create calls that omit recurrence", async () => {
await expect(
createTask(createContext(), {
schedule_kind: "recurring",
recurrence: undefined,
}),
).rejects.toThrow("Provide recurrence when schedule_kind is recurring.");
await expect(
schedulerStore().listTasksForTeam(TEST_TEAM_ID),
).resolves.toEqual([]);
});

it("edits and deletes a task from the same Slack destination", async () => {
const context = createContext();
const created = (await createTask(context)) as {
Expand Down Expand Up @@ -834,6 +897,7 @@ describe("Slack schedule tools", () => {
},
{
schedule: "In 1 minute",
schedule_kind: "one_off",
next_run_at: "2026-05-27T00:25:23.000Z",
recurrence: undefined,
},
Expand All @@ -847,6 +911,7 @@ describe("Slack schedule tools", () => {
it("stores canonical Slack destinations directly", async () => {
const result = await createTask(createContext({ channelId: "D123" }), {
schedule: "In 1 minute",
schedule_kind: "one_off",
next_run_at: "2026-05-27T00:25:23.000Z",
recurrence: undefined,
});
Expand Down Expand Up @@ -875,6 +940,7 @@ describe("Slack schedule tools", () => {

const created = await createTask(createContext(), {
schedule: "On May 26 at 9am",
schedule_kind: "one_off",
next_run_at: "2026-05-26T16:00:00.000Z",
recurrence: undefined,
timezone: undefined,
Expand All @@ -897,6 +963,7 @@ describe("Slack schedule tools", () => {

const created = await createTask(createContext(), {
schedule: "On May 26 at 9am",
schedule_kind: "one_off",
next_run_at: "2026-05-26T13:00:00.000Z",
recurrence: undefined,
timezone: undefined,
Expand Down Expand Up @@ -1178,6 +1245,7 @@ describe("Slack schedule tool wiring via getPluginTools", () => {
const result = await executeTool(tools.slackScheduleCreateTask, {
task: "Wiring test: post a weekly digest.",
schedule: "Every Monday at 9am",
schedule_kind: "recurring",
timezone: "America/Los_Angeles",
next_run_at: "2026-06-09T16:00:00.000Z",
recurrence: "weekly",
Expand Down
2 changes: 1 addition & 1 deletion specs/scheduler.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ Every active task must have an exact `nextRunAtMs` instant. For one-off tasks, t
Slack authoring may accept supported relative one-off phrases such as "tomorrow at 9am"; these must be resolved to an exact `nextRunAtMs` before storage. When a user does not provide a timezone, scheduler authoring defaults to `America/Los_Angeles` unless `JUNIOR_TIMEZONE` overrides it.
Scheduler tools accept exact ISO run timestamps only. Natural-language or relative time belongs in the agent's interpretation step before it calls the tool, not in the storage tool contract.

Model-facing scheduler tools use a small recurrence input: omit recurrence for one-off tasks, pass `daily`, `weekly`, `monthly`, or `yearly` for recurring tasks, and pass `null` only when updating an existing task to one-off. The scheduler derives stored calendar fields such as local start date, local time, weekday, month, and day-of-month from `nextRunAtMs` and timezone.
Model-facing scheduler create tools use an explicit `schedule_kind`: pass `one_off` for one-off tasks and `recurring` only when the user explicitly asks for a repeating task. For recurring create calls, also pass `daily`, `weekly`, `monthly`, or `yearly` as `recurrence`; for one-off create calls, omit `recurrence`. Model-facing update tools use the same small recurrence input for schedule edits, and pass `null` only when updating an existing task to one-off. The scheduler derives stored calendar fields such as local start date, local time, weekday, month, and day-of-month from `nextRunAtMs` and timezone.

Recurring tasks must also store a small calendar recurrence rule:

Expand Down
Loading