diff --git a/packages/junior-scheduler/src/schedule-tools.ts b/packages/junior-scheduler/src/schedule-tools.ts index 0c5e0555a..2d2c3fbc0 100644 --- a/packages/junior-scheduler/src/schedule-tools.ts +++ b/packages/junior-scheduler/src/schedule-tools.ts @@ -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; @@ -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, @@ -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.", }, ), ), @@ -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."); diff --git a/packages/junior/tests/integration/slack-schedule-tools.test.ts b/packages/junior/tests/integration/slack-schedule-tools.test.ts index c4eef0c65..492345dba 100644 --- a/packages/junior/tests/integration/slack-schedule-tools.test.ts +++ b/packages/junior/tests/integration/slack-schedule-tools.test.ts @@ -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", @@ -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({ @@ -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", @@ -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", }, ); @@ -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", }, ); @@ -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", }, ); @@ -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", }, ); @@ -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 { @@ -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, }, @@ -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, }); @@ -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, @@ -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, @@ -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", diff --git a/specs/scheduler.md b/specs/scheduler.md index 791dc4253..fd8d9e9ba 100644 --- a/specs/scheduler.md +++ b/specs/scheduler.md @@ -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: