diff --git a/apps/docs/fast-sessions.mdx b/apps/docs/fast-sessions.mdx index 26b561052..4bfa49fb3 100644 --- a/apps/docs/fast-sessions.mdx +++ b/apps/docs/fast-sessions.mdx @@ -163,13 +163,21 @@ list or cancel wakeups at any time; archiving a Session cancels all of its wakeups. Wakeups belong to the conversation and do not require an administrator. A -Session may hold up to ten active wakeups, intervals range from one minute to +Session may hold up to ten active wakeups, intervals range from one second to seven days, and recurring wakeups can be bounded by a run count or an end time. Intervals under five minutes must carry one of those bounds. A recurring wakeup whose turns fail five times in a row is retired. Deployment-wide recurring work that should run outside a conversation or report to a channel is an [automation](/automations) instead. +Relative and interval schedules use `in s|m|h|d` and +`every s|m|h|d`: seconds, minutes, hours, or days. Use `in 30s`, +not fractional `in 0.5m`. For a short recurring check, use `every 30s x3` or an +`until` end time; recurring intervals under five minutes still require a bound. +Cron schedules remain five-field expressions, such as `cron 0 9 * * 1-5`. +Delivery is best effort: `in 30s` does not guarantee a reply exactly 30 seconds +later. + ## Session and execution access Any signed-in deployment user with a Session link can view its timeline and diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts index 56030aa96..19fc00595 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts @@ -350,7 +350,7 @@ ${reactionGuidance} - Use "cancel_task" only when the user explicitly asks to stop an active task. - Call a deployment MCP tool when it can answer the request. Fast receives the same actor-authorized remote and deployment-proxied MCP tool catalog as delegated tasks; local stdio servers remain sandbox-only. Servers listed with a tool prefix expose each tool individually with its native JSON schema. On-demand servers are reached through \`find_integration_tools\` (fetch the schema by server id and tool name, or search by keywords) followed by \`call_integration_tool\`; the same acknowledgement, duplicate, and audit rules apply to both paths. - Use \`roomote_manage_custom_automations\` for custom automation lifecycle requests. It uses the current user's deployment authorization, is admin-only, and is unavailable to advisor and judge subagents. List before modifying an existing automation, use "list_models" before setting a model override, use update with "enabled" to enable or disable, and use "run_now" rather than "launch_task" to test an automation. Communicate first on a human-authored turn; platform events remain exempt. Delete only when the user explicitly requests it, and after creating an automation ask whether they want to run it now. -- Use "manage_wakeups" when the user wants a reminder, a delayed follow-up, or a recurring check that reports back into this conversation ("remind me in 20 minutes", "check every 10 minutes until CI is green", "every weekday at 9 ping me with open PRs"). The schedule is one short string: "in 20m" for a reminder, "every 10m" or "every 1m x3" for a repeating check, "cron 0 9 * * 1-5" for a calendar schedule. Send only the fields the action needs. It is scoped to this conversation and available to every participant. Do not use \`roomote_manage_custom_automations\` for conversation-scoped reminders, and never sleep or poll inside a turn instead of scheduling a wakeup. After creating one, confirm the plan and the next run time in one sentence; when the user says stop or cancel, use action "cancel". +- Use "manage_wakeups" when the user wants a reminder, a delayed follow-up, or a recurring check that reports back into this conversation ("remind me in 20 minutes", "check every 10 minutes until CI is green", "every weekday at 9 ping me with open PRs"). The schedule is one short string: "in s|m|h|d" for a reminder, "every s|m|h|d" for a repeating check, "cron 0 9 * * 1-5" for a five-field calendar schedule. Prefer "in 30s", not fractional "in 0.5m". Recurring intervals under five minutes require an x or until bound, such as "every 30s x3". Delivery is best effort; never promise an exact 30-second reply. Send only the fields the action needs. It is scoped to this conversation and available to every participant. Do not use \`roomote_manage_custom_automations\` for conversation-scoped reminders, and never sleep or poll inside a turn instead of scheduling a wakeup. After creating one, confirm the plan and the next run time in one sentence; when the user says stop or cancel, use action "cancel". ${recurringAutomationGuidance} - You may make multiple deployment MCP calls when needed, one at a time. Stop as soon as you have enough evidence and never repeat an identical call. diff --git a/packages/cloud-agents/src/server/session-wakeups/parse.test.ts b/packages/cloud-agents/src/server/session-wakeups/parse.test.ts index 149ed07fd..b7d59cf4b 100644 --- a/packages/cloud-agents/src/server/session-wakeups/parse.test.ts +++ b/packages/cloud-agents/src/server/session-wakeups/parse.test.ts @@ -7,6 +7,85 @@ const now = new Date('2026-09-04T17:00:00.000Z'); const options = { now, defaultTimeZone: 'America/New_York' }; describe('parseSessionWakeupSchedule', () => { + it.each([ + 'in 30s', + 'in 30 sec', + 'in 30 secs', + 'IN 30 seconds', + 'once in 30s', + ])('reads seconds in %s', (text) => { + const parsed = parseSessionWakeupSchedule(text, options); + expect(parsed.schedule).toEqual({ + mode: 'once', + inMinutes: 0.5, + at: '2026-09-04T17:00:30.000Z', + }); + expect(parsed.firstRunAt.toISOString()).toBe('2026-09-04T17:00:30.000Z'); + expect(parsed.maxRuns).toBeNull(); + }); + + it('normalizes equivalent seconds and minute units identically', () => { + expect(parseSessionWakeupSchedule('in 60s', options)).toEqual( + parseSessionWakeupSchedule('in 1m', options), + ); + expect(parseSessionWakeupSchedule('every 300s', options)).toEqual( + parseSessionWakeupSchedule('every 5m', options), + ); + }); + + it('requires bounds for recurring seconds below five minutes', () => { + for (const text of ['every second', 'every 30s', 'every 299s']) { + expect(() => parseSessionWakeupSchedule(text, options)).toThrow( + /run count|end time/, + ); + } + for (const text of ['every 30s x3', 'every 30 seconds for 3 runs']) { + const parsed = parseSessionWakeupSchedule(text, options); + expect(parsed.schedule).toEqual({ mode: 'interval', everyMinutes: 0.5 }); + expect(parsed.firstRunAt.toISOString()).toBe('2026-09-04T17:00:30.000Z'); + expect(parsed.maxRuns).toBe(3); + } + expect( + parseSessionWakeupSchedule('every second x3', options).schedule, + ).toEqual({ mode: 'interval', everyMinutes: 1 / 60 }); + expect( + parseSessionWakeupSchedule( + 'every 30s until 2026-09-04T17:01:00Z', + options, + ).until?.toISOString(), + ).toBe('2026-09-04T17:01:00.000Z'); + expect(() => + parseSessionWakeupSchedule( + 'every 30s until 2026-09-04T17:00:30Z', + options, + ), + ).toThrow(/later than/); + }); + + it.each([ + '0', + '-1', + '0.5', + 'NaN', + 'Infinity', + '1e2', + '999999999999999999999999999999999999', + ])('rejects invalid seconds %s', (amount) => { + expect(() => parseSessionWakeupSchedule(`in ${amount}s`, options)).toThrow( + SessionWakeupValidationError, + ); + expect(() => + parseSessionWakeupSchedule(`every ${amount}s x3`, options), + ).toThrow(SessionWakeupValidationError); + }); + + it('does not broaden fractional or compound syntax', () => { + for (const text of ['in 0.5m', 'in 1m 30s', 'in 30ms', 'every 0.5m x3']) { + expect(() => parseSessionWakeupSchedule(text, options)).toThrow( + SessionWakeupValidationError, + ); + } + }); it('reads one-shot delays in several spellings', () => { for (const text of ['in 2m', 'in 2 minutes', 'IN 2min', 'once in 2m']) { const parsed = parseSessionWakeupSchedule(text, options); @@ -153,7 +232,7 @@ describe('parseSessionWakeupSchedule', () => { /say "in \.\.\."/, ); expect(() => parseSessionWakeupSchedule('soon', options)).toThrow( - /"in m\|h\|d"/, + /"in s\|m\|h\|d"/, ); expect(() => parseSessionWakeupSchedule('every 10m until soon', options), diff --git a/packages/cloud-agents/src/server/session-wakeups/parse.ts b/packages/cloud-agents/src/server/session-wakeups/parse.ts index 0ac578aea..0ffb79868 100644 --- a/packages/cloud-agents/src/server/session-wakeups/parse.ts +++ b/packages/cloud-agents/src/server/session-wakeups/parse.ts @@ -17,31 +17,36 @@ export type ParsedSessionWakeupSchedule = { until: Date | null; }; -const UNIT_MINUTES: Record = { - m: 1, - min: 1, - mins: 1, - minute: 1, - minutes: 1, - h: 60, - hr: 60, - hrs: 60, - hour: 60, - hours: 60, - d: 24 * 60, - day: 24 * 60, - days: 24 * 60, +const UNIT_SECONDS: Record = { + s: 1, + sec: 1, + secs: 1, + second: 1, + seconds: 1, + m: 60, + min: 60, + mins: 60, + minute: 60, + minutes: 60, + h: 3600, + hr: 3600, + hrs: 3600, + hour: 3600, + hours: 3600, + d: 24 * 3600, + day: 24 * 3600, + days: 24 * 3600, }; // Every pattern below runs on text whose whitespace has already been // collapsed to single spaces and whose length is capped by the contract, so // the patterns use literal single spaces and stay linear. -const DURATION = String.raw`(\d+) ?(m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)`; +const DURATION = String.raw`(\d+) ?(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)`; const DURATION_RE = new RegExp(`^${DURATION}$`, 'i'); const IN_RE = new RegExp(`^(?:once )?in ${DURATION}$`, 'i'); const AT_RE = /^(?:once )?at (\S+)$/i; const EVERY_RE = new RegExp( - `^every (?:${DURATION}|(minute|hour|day))(?: (.*))?$`, + `^every (?:${DURATION}|(second|minute|hour|day))(?: (.*))?$`, 'i', ); const CRON_RE = /^cron ((?:\S+ ){4}\S+)(?: (.*))?$/i; @@ -59,7 +64,7 @@ function invalid(text: string, detail?: string): SessionWakeupValidationError { function durationMinutes(amount: string, unit: string): number { const minutes = - Number.parseInt(amount, 10) * UNIT_MINUTES[unit.toLowerCase()]!; + (Number.parseInt(amount, 10) * UNIT_SECONDS[unit.toLowerCase()]!) / 60; if (!Number.isFinite(minutes) || minutes <= 0) { throw new SessionWakeupValidationError('A duration must be positive.'); } @@ -147,7 +152,7 @@ export function parseSessionWakeupSchedule( const everyMatch = EVERY_RE.exec(text); if (everyMatch) { const everyMinutes = everyMatch[3] - ? UNIT_MINUTES[everyMatch[3].toLowerCase()]! + ? UNIT_SECONDS[everyMatch[3].toLowerCase()]! / 60 : durationMinutes(everyMatch[1]!, everyMatch[2]!); const normalized = normalizeSessionWakeupSchedule( { mode: 'interval', everyMinutes }, diff --git a/packages/cloud-agents/src/server/session-wakeups/schedule.test.ts b/packages/cloud-agents/src/server/session-wakeups/schedule.test.ts index 4dd45aadf..bcccd22a4 100644 --- a/packages/cloud-agents/src/server/session-wakeups/schedule.test.ts +++ b/packages/cloud-agents/src/server/session-wakeups/schedule.test.ts @@ -13,6 +13,73 @@ const now = new Date('2026-09-04T17:00:00.000Z'); const options = { now, defaultTimeZone: 'America/New_York' }; describe('normalizeSessionWakeupSchedule', () => { + it.each([1, 10, 30, 31, 59, 61, 90, 299, 300])( + 'keeps %i seconds exact through first and next runs', + (seconds) => { + const inMinutes = seconds / 60; + const once = normalizeSessionWakeupSchedule( + { mode: 'once', inMinutes }, + options, + ); + const interval = normalizeSessionWakeupSchedule( + { mode: 'interval', everyMinutes: inMinutes }, + options, + ); + expect(once.firstRunAt.getTime()).toBe(now.getTime() + seconds * 1000); + expect(interval.firstRunAt).toEqual(once.firstRunAt); + expect(computeNextSessionWakeupRunAt(once.schedule, now)).toEqual( + once.firstRunAt, + ); + expect( + computeNextSessionWakeupRunAt(once.schedule, once.firstRunAt), + ).toBeNull(); + expect( + resolveSessionWakeupNextRun({ + schedule: interval.schedule, + firedAt: once.firstRunAt, + runCountAfterFire: 1, + maxRuns: 2, + until: null, + })?.getTime(), + ).toBe(now.getTime() + seconds * 2000); + expect( + resolveSessionWakeupNextRun({ + schedule: interval.schedule, + firedAt: once.firstRunAt, + runCountAfterFire: 2, + maxRuns: 2, + until: null, + }), + ).toBeNull(); + expect( + resolveSessionWakeupNextRun({ + schedule: interval.schedule, + firedAt: once.firstRunAt, + runCountAfterFire: 1, + maxRuns: null, + until: once.firstRunAt, + }), + ).toBeNull(); + }, + ); + + it.each([0, -1, NaN, Infinity, 0.001, 0.025, 43_201])( + 'rejects invalid normalized duration %s', + (minutes) => { + expect(() => + normalizeSessionWakeupSchedule( + { mode: 'once', inMinutes: minutes }, + options, + ), + ).toThrow(SessionWakeupValidationError); + expect(() => + normalizeSessionWakeupSchedule( + { mode: 'interval', everyMinutes: minutes }, + options, + ), + ).toThrow(SessionWakeupValidationError); + }, + ); it('resolves a relative once schedule against now', () => { const result = normalizeSessionWakeupSchedule( { mode: 'once', inMinutes: 20 }, @@ -206,6 +273,18 @@ describe('validateSessionWakeupCaps', () => { describe('describeSessionWakeupSchedule', () => { it('renders each mode for humans', () => { + expect( + describeSessionWakeupSchedule({ mode: 'interval', everyMinutes: 0.5 }), + ).toBe('every 30 seconds'); + expect( + describeSessionWakeupSchedule({ mode: 'interval', everyMinutes: 1 / 60 }), + ).toBe('every 1 second'); + expect( + describeSessionWakeupSchedule({ + mode: 'interval', + everyMinutes: 61 / 60, + }), + ).toBe('every 61 seconds'); expect( describeSessionWakeupSchedule({ mode: 'interval', everyMinutes: 90 }), ).toBe('every 90 minutes'); diff --git a/packages/cloud-agents/src/server/session-wakeups/schedule.ts b/packages/cloud-agents/src/server/session-wakeups/schedule.ts index d5336fbc0..f672909fc 100644 --- a/packages/cloud-agents/src/server/session-wakeups/schedule.ts +++ b/packages/cloud-agents/src/server/session-wakeups/schedule.ts @@ -1,10 +1,9 @@ import { CronExpressionParser } from 'cron-parser'; import { - SESSION_WAKEUP_MAX_INTERVAL_MINUTES, SESSION_WAKEUP_MAX_ONCE_HORIZON_MINUTES, - SESSION_WAKEUP_MIN_INTERVAL_MINUTES, SESSION_WAKEUP_UNCAPPED_MIN_INTERVAL_MINUTES, + sessionWakeupScheduleSchema, type SessionWakeupSchedule, } from '@roomote/types'; @@ -88,8 +87,20 @@ export function normalizeSessionWakeupSchedule( 'A once schedule needs inMinutes or at.', ); } + if ( + hasDelay && + !sessionWakeupScheduleSchema.safeParse({ + mode: 'once', + at: now.toISOString(), + inMinutes: input.inMinutes, + }).success + ) { + throw new SessionWakeupValidationError( + 'A once delay must be a positive whole number of seconds, at most 30 days out.', + ); + } const at = hasDelay - ? new Date(now.getTime() + input.inMinutes! * MINUTE_MS) + ? new Date(now.getTime() + Math.round(input.inMinutes! * MINUTE_MS)) : parseIsoDate(input.at!, 'at'); if (at.getTime() <= now.getTime()) { throw new SessionWakeupValidationError( @@ -114,18 +125,16 @@ export function normalizeSessionWakeupSchedule( }; } case 'interval': { - if ( - !Number.isInteger(input.everyMinutes) || - input.everyMinutes < SESSION_WAKEUP_MIN_INTERVAL_MINUTES || - input.everyMinutes > SESSION_WAKEUP_MAX_INTERVAL_MINUTES - ) { + if (!sessionWakeupScheduleSchema.safeParse(input).success) { throw new SessionWakeupValidationError( - `everyMinutes must be a whole number between ${SESSION_WAKEUP_MIN_INTERVAL_MINUTES} and ${SESSION_WAKEUP_MAX_INTERVAL_MINUTES}.`, + 'An interval must be a positive whole number of seconds, at most 7 days.', ); } return { schedule: { mode: 'interval', everyMinutes: input.everyMinutes }, - firstRunAt: new Date(now.getTime() + input.everyMinutes * MINUTE_MS), + firstRunAt: new Date( + now.getTime() + Math.round(input.everyMinutes * MINUTE_MS), + ), }; } case 'cron': { @@ -168,7 +177,9 @@ export function computeNextSessionWakeupRunAt( return at.getTime() > from.getTime() ? at : null; } case 'interval': - return new Date(from.getTime() + schedule.everyMinutes * MINUTE_MS); + return new Date( + from.getTime() + Math.round(schedule.everyMinutes * MINUTE_MS), + ); case 'cron': return nextCronOccurrence(schedule.expression, schedule.timezone, from); } @@ -274,6 +285,10 @@ function estimateCronMinGapMinutes( } function formatMinutes(minutes: number): string { + if (!Number.isInteger(minutes)) { + const seconds = Math.round(minutes * 60); + return `${seconds} second${seconds === 1 ? '' : 's'}`; + } if (minutes % (24 * 60) === 0) { const days = minutes / (24 * 60); return `${days} day${days === 1 ? '' : 's'}`; diff --git a/packages/cloud-agents/src/server/session-wakeups/service.test.ts b/packages/cloud-agents/src/server/session-wakeups/service.test.ts new file mode 100644 index 000000000..63fc80978 --- /dev/null +++ b/packages/cloud-agents/src/server/session-wakeups/service.test.ts @@ -0,0 +1,158 @@ +import { + db, + eq, + fastAgentConversations, + listSessionWakeups, + sessionWakeups, + userFactory, + users, +} from '@roomote/db/server'; + +import { enqueueSessionWakeupFireBestEffort } from './queue'; +import { + handleManageWakeupsToolCall, + type SessionWakeupActor, +} from './service'; + +vi.mock('./queue', () => ({ + enqueueSessionWakeupFireBestEffort: vi.fn(), +})); + +const now = new Date('2026-09-05T12:00:00.000Z'); +const createInput = { + action: 'create' as const, + name: 'Reminder', + prompt: 'Check the deploy.', + schedule: 'in 30s', +}; + +describe('handleManageWakeupsToolCall relative reminders', () => { + let actor: SessionWakeupActor; + + beforeEach(async () => { + vi.clearAllMocks(); + const user = await userFactory.create(); + const [conversation] = await db + .insert(fastAgentConversations) + .values({ + userId: user.id, + surface: 'web', + workspaceId: user.id, + conversationId: `conversation-${crypto.randomUUID()}`, + }) + .returning(); + actor = { conversationId: conversation!.id, userId: user.id }; + // Freeze new Date() without replacing timers used by the database client. + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(now); + }); + + afterEach(async () => { + vi.useRealTimers(); + await db + .delete(sessionWakeups) + .where(eq(sessionWakeups.conversationId, actor.conversationId)); + await db + .delete(fastAgentConversations) + .where(eq(fastAgentConversations.id, actor.conversationId)); + await db.delete(users).where(eq(users.id, actor.userId)); + vi.clearAllMocks(); + }); + + it.each(['in 30s', 'in 30 seconds'])( + 'persists %s and reuses its original run time on a later retry', + async (schedule) => { + const input = { ...createInput, schedule }; + const nextRunAt = new Date(now.getTime() + 30_000); + const result = await handleManageWakeupsToolCall(actor, input); + + expect(result).toMatchObject({ + success: true, + duplicate: false, + wakeup: { + schedule: { + mode: 'once', + at: nextRunAt.toISOString(), + inMinutes: 0.5, + }, + nextRunAt: nextRunAt.toISOString(), + reportPolicy: 'always', + status: 'active', + }, + }); + const rows = await listSessionWakeups(actor.conversationId); + expect(rows).toHaveLength(1); + const stored = rows[0]!; + expect(stored).toMatchObject({ + schedule: { mode: 'once', at: nextRunAt.toISOString(), inMinutes: 0.5 }, + nextRunAt, + reportPolicy: 'always', + status: 'active', + }); + expect( + enqueueSessionWakeupFireBestEffort, + ).toHaveBeenCalledExactlyOnceWith({ + wakeupId: stored.id, + runAt: nextRunAt.getTime(), + }); + + vi.setSystemTime(new Date(now.getTime() + 5_000)); + const retry = await handleManageWakeupsToolCall(actor, input); + expect(retry).toMatchObject({ + success: true, + duplicate: true, + wakeup: result.wakeup, + }); + expect(await listSessionWakeups(actor.conversationId)).toEqual(rows); + expect(enqueueSessionWakeupFireBestEffort).toHaveBeenCalledTimes(1); + }, + ); + + it('deduplicates equivalent seconds and minutes schedules', async () => { + const nextRunAt = new Date(now.getTime() + 60_000); + const first = await handleManageWakeupsToolCall(actor, { + ...createInput, + schedule: 'in 60s', + }); + expect(first).toMatchObject({ + success: true, + duplicate: false, + wakeup: { + schedule: { mode: 'once', inMinutes: 1 }, + nextRunAt: nextRunAt.toISOString(), + }, + }); + + vi.setSystemTime(new Date(now.getTime() + 5_000)); + expect( + await handleManageWakeupsToolCall(actor, { + ...createInput, + schedule: 'in 1m', + }), + ).toMatchObject({ success: true, duplicate: true, wakeup: first.wakeup }); + const rows = await listSessionWakeups(actor.conversationId); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + schedule: { mode: 'once', at: nextRunAt.toISOString(), inMinutes: 1 }, + nextRunAt, + }); + expect(enqueueSessionWakeupFireBestEffort).toHaveBeenCalledExactlyOnceWith({ + wakeupId: rows[0]!.id, + runAt: nextRunAt.getTime(), + }); + }); + + it('rejects fractional textual minutes without persisting or queueing', async () => { + expect( + await handleManageWakeupsToolCall(actor, { + ...createInput, + schedule: 'in 0.5m', + }), + ).toMatchObject({ + success: false, + error: expect.stringContaining('Could not read the schedule "in 0.5m"'), + }); + expect(await listSessionWakeups(actor.conversationId)).toEqual([]); + expect(enqueueSessionWakeupFireBestEffort).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/db/src/lib/__tests__/session-wakeups.test.ts b/packages/db/src/lib/__tests__/session-wakeups.test.ts index 6cd48a8fb..efe8c4338 100644 --- a/packages/db/src/lib/__tests__/session-wakeups.test.ts +++ b/packages/db/src/lib/__tests__/session-wakeups.test.ts @@ -64,50 +64,53 @@ afterEach(async () => { }); describe('session wakeup helpers', () => { - it('deduplicates relative retries with changed resolved times, not distinct delays or prompts', async () => { - const { user, conversation } = await makeConversation(); - const input = { - conversationId: conversation.id, - createdByUserId: user.id, - name: 'Reminder', - prompt: 'Check the deploy.', - schedule: { - mode: 'once' as const, - at: firstRunAt.toISOString(), - inMinutes: 2, - }, - reportPolicy: 'always' as const, - maxRuns: null, - until: null, - nextRunAt: firstRunAt, - }; - const first = await admitSessionWakeup(input); - expect(first.outcome).toBe('created'); - const later = new Date(firstRunAt.getTime() + 5_000); - const retry = { - ...input, - prompt: ' CHECK THE DEPLOY. ', - schedule: { ...input.schedule, at: later.toISOString() }, - nextRunAt: later, - }; - expect(await admitSessionWakeup(retry)).toEqual({ - ...first, - outcome: 'duplicate', - }); - const [persisted] = await listSessionWakeups(conversation.id); - expect(persisted!.schedule).toEqual(input.schedule); - expect(persisted!.nextRunAt).toEqual(firstRunAt); - expect( - await admitSessionWakeup({ - ...retry, - schedule: { ...retry.schedule, inMinutes: 3 }, - }), - ).toMatchObject({ outcome: 'created' }); - expect( - await admitSessionWakeup({ ...retry, prompt: 'Check another deploy.' }), - ).toMatchObject({ outcome: 'created' }); - expect(await listSessionWakeups(conversation.id)).toHaveLength(3); - }); + it.each([2, 0.5, 31 / 60])( + 'deduplicates relative retries (%s minutes) with changed resolved times, not distinct delays or prompts', + async (inMinutes) => { + const { user, conversation } = await makeConversation(); + const input = { + conversationId: conversation.id, + createdByUserId: user.id, + name: 'Reminder', + prompt: 'Check the deploy.', + schedule: { + mode: 'once' as const, + at: firstRunAt.toISOString(), + inMinutes, + }, + reportPolicy: 'always' as const, + maxRuns: null, + until: null, + nextRunAt: firstRunAt, + }; + const first = await admitSessionWakeup(input); + expect(first.outcome).toBe('created'); + const later = new Date(firstRunAt.getTime() + 5_000); + const retry = { + ...input, + prompt: ' CHECK THE DEPLOY. ', + schedule: { ...input.schedule, at: later.toISOString() }, + nextRunAt: later, + }; + expect(await admitSessionWakeup(retry)).toEqual({ + ...first, + outcome: 'duplicate', + }); + const [persisted] = await listSessionWakeups(conversation.id); + expect(persisted!.schedule).toEqual(input.schedule); + expect(persisted!.nextRunAt).toEqual(firstRunAt); + expect( + await admitSessionWakeup({ + ...retry, + schedule: { ...retry.schedule, inMinutes: 3 }, + }), + ).toMatchObject({ outcome: 'created' }); + expect( + await admitSessionWakeup({ ...retry, prompt: 'Check another deploy.' }), + ).toMatchObject({ outcome: 'created' }); + expect(await listSessionWakeups(conversation.id)).toHaveLength(3); + }, + ); it('does not infer relative identity from legacy or absolute one-shots', async () => { const { user, conversation } = await makeConversation(); @@ -136,6 +139,8 @@ describe('session wakeup helpers', () => { }); it.each([ + { everyMinutes: 0.5, mode: 'interval' as const }, + { everyMinutes: 31 / 60, mode: 'interval' as const }, { mode: 'once' as const, at: firstRunAt.toISOString() }, { mode: 'cron' as const, expression: '*/10 * * * *', timezone: 'UTC' }, ])( diff --git a/packages/types/src/session-wakeups.test.ts b/packages/types/src/session-wakeups.test.ts index 7c98901c8..040a84c50 100644 --- a/packages/types/src/session-wakeups.test.ts +++ b/packages/types/src/session-wakeups.test.ts @@ -19,13 +19,26 @@ describe('manage wakeups tool contract', () => { expect( sessionWakeupScheduleSchema.parse({ ...absolute, inMinutes: 2 }), ).toEqual({ ...absolute, inMinutes: 2 }); - for (const inMinutes of [0, -1, 1.5, 43_201, '2', null]) { + for (const inMinutes of [0, -1, 0.025, NaN, Infinity, 43_201, '2', null]) { expect( sessionWakeupScheduleSchema.safeParse({ ...absolute, inMinutes }) .success, ).toBe(false); } }); + it.each([1 / 60, 0.5, 31 / 60, 1, 1.5])( + 'accepts whole seconds stored as %s minutes', + (minutes) => { + const once = { + mode: 'once', + at: '2026-09-04T17:00:30.000Z', + inMinutes: minutes, + }; + const interval = { mode: 'interval', everyMinutes: minutes }; + expect(sessionWakeupScheduleSchema.parse(once)).toEqual(once); + expect(sessionWakeupScheduleSchema.parse(interval)).toEqual(interval); + }, + ); it('keeps every supported action in the shared Zod schema', () => { for (const action of MANAGE_WAKEUPS_ACTIONS) { expect(manageWakeupsInputSchema.parse({ action })).toEqual({ action }); @@ -39,6 +52,10 @@ describe('manage wakeups tool contract', () => { ); expect(getFastAgentNativeAcpKind('manage_wakeups')).toBe('task'); expect(MANAGE_WAKEUPS_TOOL.description).toContain('"in 20m"'); + expect(MANAGE_WAKEUPS_TOOL.description).toContain('"in 30s"'); + expect(MANAGE_WAKEUPS_TOOL.inputSchema.schedule.description).toContain( + '"every 30s x3"', + ); expect(MANAGE_WAKEUPS_TOOL.description).toContain('There is no pause.'); expect(MANAGE_WAKEUPS_TOOL.description).toContain( 'Never poll, sleep, or wait', diff --git a/packages/types/src/session-wakeups.ts b/packages/types/src/session-wakeups.ts index 9546409c2..bb9b58339 100644 --- a/packages/types/src/session-wakeups.ts +++ b/packages/types/src/session-wakeups.ts @@ -21,7 +21,7 @@ export const SESSION_WAKEUP_PROMPT_MIN_LENGTH = 10; export const SESSION_WAKEUP_SCHEDULE_MAX_LENGTH = 160; /** Active wakeups one conversation may hold at once. */ export const MAX_ACTIVE_SESSION_WAKEUPS = 10; -export const SESSION_WAKEUP_MIN_INTERVAL_MINUTES = 1; +export const SESSION_WAKEUP_MIN_INTERVAL_MINUTES = 1 / 60; /** Seven days. */ export const SESSION_WAKEUP_MAX_INTERVAL_MINUTES = 7 * 24 * 60; /** @@ -50,6 +50,19 @@ export const SESSION_WAKEUP_REPORT_POLICIES = [ export type SessionWakeupReportPolicy = (typeof SESSION_WAKEUP_REPORT_POLICIES)[number]; +// Keep the persisted minute fields; whole seconds have a canonical fractional +// minute representation, so equivalent units retain the same dedupe identity. +const durationMinutesSchema = (maximum: number) => + z + .number() + .finite() + .min(SESSION_WAKEUP_MIN_INTERVAL_MINUTES) + .max(maximum) + .refine( + (minutes) => minutes === Math.round(minutes * 60) / 60, + 'Duration must be a whole number of seconds.', + ); + /** The normalized schedule persisted with a wakeup. */ export const sessionWakeupScheduleSchema = z.discriminatedUnion('mode', [ z @@ -57,16 +70,16 @@ export const sessionWakeupScheduleSchema = z.discriminatedUnion('mode', [ mode: z.literal('once'), at: z.string(), // Stable relative identity for retries; at remains the firing time. - inMinutes: z - .number() - .int() - .positive() - .max(SESSION_WAKEUP_MAX_ONCE_HORIZON_MINUTES) - .optional(), + inMinutes: durationMinutesSchema( + SESSION_WAKEUP_MAX_ONCE_HORIZON_MINUTES, + ).optional(), }) .strict(), z - .object({ mode: z.literal('interval'), everyMinutes: z.number().int() }) + .object({ + mode: z.literal('interval'), + everyMinutes: durationMinutesSchema(SESSION_WAKEUP_MAX_INTERVAL_MINUTES), + }) .strict(), z .object({ @@ -86,9 +99,9 @@ export function isSessionWakeupRecurring( } export const SESSION_WAKEUP_SCHEDULE_GRAMMAR = `One of: -- "in m|h|d" for a one-shot delay, e.g. "in 2m", "in 90m", "in 3h" (preferred for reminders and delayed follow-ups) +- "in s|m|h|d" for a one-shot delay with a positive whole number, e.g. "in 30s", "in 2m", "in 90m", "in 3h" (preferred for reminders and delayed follow-ups; use "in 30s", not "in 0.5m") - "at " for a one-shot at an absolute time, e.g. "at 2026-09-04T15:00:00-04:00" -- "every m|h|d" for a repeating interval, e.g. "every 10m", "every 6h"; add "x" to stop after that many runs ("every 1m x3") or "until " to stop after a time ("every 10m until 2026-09-04T18:00:00Z") +- "every s|m|h|d" for a repeating interval with a positive whole number, e.g. "every 10m", "every 6h"; add "x" to stop after that many runs ("every 30s x3") or "until " to stop after a time ("every 10m until 2026-09-04T18:00:00Z"); intervals under 5 minutes require one of these bounds - "cron [IANA timezone]" for a calendar schedule, e.g. "cron 0 9 * * 1-5 America/New_York" (timezone defaults to the deployment timezone); "x" and "until " work here too, and a cron that fires more often than every 5 minutes requires one of them`; export const MANAGE_WAKEUPS_ACTIONS = [ @@ -152,10 +165,10 @@ export const MANAGE_WAKEUPS_TOOL_NAME = 'manage_wakeups' as const; export const MANAGE_WAKEUPS_TOOL_DESCRIPTION = `Schedule this conversation to wake itself up later, once or on a cadence. When a wakeup fires, you receive a scheduled_wakeup platform event in this same conversation with the full history still in context, so the prompt can be brief and refer to things discussed here. Use it for reminders ("remind me in 20 minutes", "ping me at 3pm") and for monitors ("check every 10 minutes whether CI is green", "every weekday at 9am summarize open PRs"). -The schedule is one short string. Reminders and delayed follow-ups use "in m" ("in 20m"); use "at " only for an explicit absolute time. Repeating checks use "every m" or "cron ...", optionally with "x" or "until ". Pick an interval that matches how fast the monitored thing actually changes, not how soon you want an answer; intervals under ${SESSION_WAKEUP_UNCAPPED_MIN_INTERVAL_MINUTES} minutes need "x" or "until". +The schedule is one short string with positive whole-number s/m/h/d durations. Reminders and delayed follow-ups use "in 30s" or "in 20m", not fractional units such as "in 0.5m"; use "at " only for an explicit absolute time. Repeating checks use "every 30s x3", "every 10m" or "cron ...", optionally with "x" or "until ". Pick an interval that matches how fast the monitored thing actually changes, not how soon you want an answer; intervals under ${SESSION_WAKEUP_UNCAPPED_MIN_INTERVAL_MINUTES} minutes need "x" or "until". - A monitor keeps running until the user cancels it or the condition definitively resolves. A run that finds nothing new is still useful. When a monitored condition resolves, tell the user and cancel the wakeup. -- Results arrive automatically as a new turn in this conversation. Never poll, sleep, or wait for a wakeup inside a turn. +- Results arrive automatically as a new turn in this conversation. Delivery is best effort, not an exact-time guarantee. Never poll, sleep, or wait for a wakeup inside a turn. - When the user says stop, cancel, remove, delete, or end a wakeup, use cancel. There is no pause. - Creating a wakeup that matches an active one (same prompt and schedule) returns the existing wakeup instead of a duplicate. At most ${MAX_ACTIVE_SESSION_WAKEUPS} wakeups may be active per conversation. - Only send the fields the action needs; omit the rest. After creating a wakeup, confirm what will happen and when in one short sentence using the returned nextRunAt.`;