refactor(schedule): bound fixed-rate reminders
This commit is contained in:
@@ -1,503 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { Cron } from 'croner'
|
||||
import {
|
||||
ScheduleId,
|
||||
ScheduleInputError,
|
||||
ScheduleLogError,
|
||||
canonicalizeCronExpression,
|
||||
createCronScheduleRecord,
|
||||
decodeScheduleChange,
|
||||
foldScheduleEvents,
|
||||
resolveCronOccurrence,
|
||||
scheduleReminderPresentation,
|
||||
scheduleView,
|
||||
} from '../src/domain.ts'
|
||||
|
||||
function event(data: unknown, seq: number): SessionEvent {
|
||||
return { type: 'schedule/change', seq, time: 0, data } as SessionEvent
|
||||
}
|
||||
|
||||
function cronCreate(
|
||||
id = 'schedule-cron',
|
||||
scheduledAt = '2026-08-07T01:00:00.000Z',
|
||||
cron = '0 9 * * 1,2,3,4,5',
|
||||
timeZone = 'Asia/Shanghai',
|
||||
) {
|
||||
return {
|
||||
version: 1,
|
||||
operation: 'create',
|
||||
schedule: { id, kind: 'cron', prompt: 'daily review', cron, timeZone, scheduledAt },
|
||||
}
|
||||
}
|
||||
|
||||
function expectInputCode(run: () => unknown, code: ScheduleInputError['code']): void {
|
||||
try {
|
||||
run()
|
||||
throw new Error(`expected ${code}`)
|
||||
} catch (error: unknown) {
|
||||
expect(error).toBeInstanceOf(ScheduleInputError)
|
||||
expect((error as ScheduleInputError).code).toBe(code)
|
||||
}
|
||||
}
|
||||
|
||||
describe('restricted cron grammar and frequency proof', () => {
|
||||
it.each([
|
||||
['00 09 * * 1,2,3,4,5', '0 9 * * 1,2,3,4,5'],
|
||||
['0 0 * */01 *', '0 0 * * *'],
|
||||
['5-20/05 1-3 * * *', '5-20/5 1-3 * * *'],
|
||||
['05 01 01,15 01,12 *', '5 1 1,15 1,12 *'],
|
||||
['0 0 * * 7', '0 0 * * 7'],
|
||||
['0 9 * * */7', '0 9 * * */7'],
|
||||
])('canonicalizes %s', (input, canonical) => {
|
||||
expect(canonicalizeCronExpression(input)).toBe(canonical)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'',
|
||||
' 0 0 * * *',
|
||||
'0 0 * * * ',
|
||||
'0 0 * *',
|
||||
'0 0 0 * * *',
|
||||
'@daily',
|
||||
'0 0 * JAN *',
|
||||
'0 0 * * MON',
|
||||
'0 0 ? * *',
|
||||
'0 0 L * *',
|
||||
'0 0 W * *',
|
||||
'0 0 * * 1#2',
|
||||
'-1 0 * * *',
|
||||
'1.5 0 * * *',
|
||||
'60 0 * * *',
|
||||
'0 24 * * *',
|
||||
'0 0 0 * *',
|
||||
'0 0 32 * *',
|
||||
'0 0 * 13 *',
|
||||
'0 0 * * 8',
|
||||
'0,0 0 * * *',
|
||||
'2,1 0 * * *',
|
||||
'1,2-3 0 * * *',
|
||||
'2-2 0 * * *',
|
||||
'3-2 0 * * *',
|
||||
'*/0 0 * * *',
|
||||
'*/61 0 * * *',
|
||||
'1-5/61 0 * * *',
|
||||
'1-1/2 0 * * *',
|
||||
'0 0 * * 0,7',
|
||||
'0 0 * * 0-7',
|
||||
'0 0 * * */8',
|
||||
'0 0 * * 0-6/8',
|
||||
'0 0 * * 1-7/8',
|
||||
'0 0 1 * 1',
|
||||
])('rejects unsupported grammar %s', (input) => {
|
||||
expectInputCode(() => canonicalizeCronExpression(input), 'invalid_rule')
|
||||
})
|
||||
|
||||
it('proves same-day and cycle-seam frequency while allowing the five-minute boundary', () => {
|
||||
expectInputCode(() => canonicalizeCronExpression('0,4 * * * *'), 'frequency_too_high')
|
||||
expectInputCode(() => canonicalizeCronExpression('3,59 0,23 * * *'), 'frequency_too_high')
|
||||
expect(canonicalizeCronExpression('0,5 * * * *')).toBe('0,5 * * * *')
|
||||
expect(canonicalizeCronExpression('4,59 0,23 * * *')).toBe('4,59 0,23 * * *')
|
||||
expect(canonicalizeCronExpression('3,59 0,23 * * 1')).toBe('3,59 0,23 * * 1')
|
||||
expect(canonicalizeCronExpression('3,59 0,23 29 2 *')).toBe('3,59 0,23 29 2 *')
|
||||
expectInputCode(() => canonicalizeCronExpression('3,59 0,23 * * 5,6'), 'frequency_too_high')
|
||||
expect(canonicalizeCronExpression('* * 31 2 *')).toBe('* * 31 2 *')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Croner calendar adapter', () => {
|
||||
it('creates a canonical explicit-zone record and crosses from 2999 into 3000', () => {
|
||||
expect(createCronScheduleRecord(
|
||||
ScheduleId('schedule-workday'),
|
||||
' review metrics ',
|
||||
'00 09 * * 1,2,3,4,5',
|
||||
'US/Eastern',
|
||||
Date.parse('2026-08-06T12:00:00.000Z'),
|
||||
)).toEqual({
|
||||
id: 'schedule-workday',
|
||||
kind: 'cron',
|
||||
prompt: 'review metrics',
|
||||
cron: '0 9 * * 1,2,3,4,5',
|
||||
timeZone: 'America/New_York',
|
||||
scheduledAt: '2026-08-06T13:00:00.000Z',
|
||||
})
|
||||
expect(createCronScheduleRecord(
|
||||
ScheduleId('schedule-3000'),
|
||||
'new millennium',
|
||||
'0 0 1 1 *',
|
||||
'UTC',
|
||||
Date.parse('2999-12-31T23:59:59.999Z'),
|
||||
).scheduledAt).toBe('3000-01-01T00:00:00.000Z')
|
||||
})
|
||||
|
||||
it('owns forward and reverse calendar search across years 0001 through 0100', () => {
|
||||
expect(createCronScheduleRecord(
|
||||
ScheduleId('schedule-year-1'),
|
||||
'year one',
|
||||
'0 0 * * *',
|
||||
'UTC',
|
||||
Date.parse('0001-01-01T00:00:00.000Z'),
|
||||
).scheduledAt).toBe('0001-01-02T00:00:00.000Z')
|
||||
expect(createCronScheduleRecord(
|
||||
ScheduleId('schedule-year-100'),
|
||||
'year one hundred',
|
||||
'0 0 * * *',
|
||||
'UTC',
|
||||
Date.parse('0099-12-31T00:00:00.000Z'),
|
||||
).scheduledAt).toBe('0100-01-01T00:00:00.000Z')
|
||||
expect(createCronScheduleRecord(
|
||||
ScheduleId('schedule-low-leap'),
|
||||
'low leap',
|
||||
'0 0 29 2 *',
|
||||
'UTC',
|
||||
Date.parse('0001-01-01T00:00:00.000Z'),
|
||||
).scheduledAt).toBe('0004-02-29T00:00:00.000Z')
|
||||
const historicalOffset = createCronScheduleRecord(
|
||||
ScheduleId('schedule-low-offset'),
|
||||
'low offset',
|
||||
'0 0 29 2 *',
|
||||
'Pacific/Kiritimati',
|
||||
Date.parse('0001-01-01T00:00:00.000Z'),
|
||||
)
|
||||
expect(new Date(historicalOffset.scheduledAt).getUTCFullYear()).toBeGreaterThan(109)
|
||||
const baseline = createCronScheduleRecord(
|
||||
ScheduleId('schedule-reverse-100'),
|
||||
'reverse one hundred',
|
||||
'0 0 * * *',
|
||||
'UTC',
|
||||
Date.parse('0099-12-30T00:00:00.000Z'),
|
||||
)
|
||||
expect(resolveCronOccurrence(baseline, Date.parse('0100-01-01T00:00:00.000Z'))).toEqual({
|
||||
occurrenceAt: '0100-01-01T00:00:00.000Z',
|
||||
nextScheduledAt: '0100-01-02T00:00:00.000Z',
|
||||
})
|
||||
const yearOne = createCronScheduleRecord(
|
||||
ScheduleId('schedule-reverse-1'),
|
||||
'reverse year one',
|
||||
'0 0 * * *',
|
||||
'UTC',
|
||||
Date.parse('0001-01-01T00:00:00.000Z'),
|
||||
)
|
||||
expect(resolveCronOccurrence(yearOne, Date.parse(yearOne.scheduledAt))).toEqual({
|
||||
occurrenceAt: yearOne.scheduledAt,
|
||||
nextScheduledAt: '0001-01-03T00:00:00.000Z',
|
||||
})
|
||||
expect(createCronScheduleRecord(
|
||||
ScheduleId('schedule-low-year-positive-offset-seam'),
|
||||
'positive offset seam',
|
||||
'0 0 1 1 *',
|
||||
'Etc/GMT-14',
|
||||
Date.parse('0108-12-31T23:59:59.999Z'),
|
||||
).scheduledAt).toBe('0109-12-31T10:00:00.000Z')
|
||||
})
|
||||
|
||||
it('skips a DST gap and chooses the first instant in an overlap', () => {
|
||||
const gap = createCronScheduleRecord(
|
||||
ScheduleId('schedule-gap'),
|
||||
'gap',
|
||||
'30 2 * * *',
|
||||
'America/New_York',
|
||||
Date.parse('2026-03-08T05:00:00.000Z'),
|
||||
)
|
||||
expect(gap.scheduledAt).toBe('2026-03-09T06:30:00.000Z')
|
||||
const gapBaseline = {
|
||||
...gap,
|
||||
scheduledAt: '2026-03-07T07:30:00.000Z',
|
||||
}
|
||||
expect(resolveCronOccurrence(gapBaseline, Date.parse('2026-03-08T08:00:00.000Z'))).toEqual({
|
||||
occurrenceAt: gapBaseline.scheduledAt,
|
||||
nextScheduledAt: '2026-03-09T06:30:00.000Z',
|
||||
})
|
||||
|
||||
const overlap = createCronScheduleRecord(
|
||||
ScheduleId('schedule-overlap'),
|
||||
'overlap',
|
||||
'30 1 * * *',
|
||||
'America/New_York',
|
||||
Date.parse('2026-10-31T06:00:00.000Z'),
|
||||
)
|
||||
expect(overlap.scheduledAt).toBe('2026-11-01T05:30:00.000Z')
|
||||
expect(resolveCronOccurrence({
|
||||
...overlap,
|
||||
scheduledAt: '2026-10-31T05:30:00.000Z',
|
||||
}, Date.parse('2026-11-01T06:00:00.000Z'))).toEqual({
|
||||
occurrenceAt: '2026-11-01T05:30:00.000Z',
|
||||
nextScheduledAt: '2026-11-02T06:30:00.000Z',
|
||||
})
|
||||
expect(resolveCronOccurrence(overlap, Date.parse('2026-11-01T07:00:00.000Z'))).toEqual({
|
||||
occurrenceAt: '2026-11-01T05:30:00.000Z',
|
||||
nextScheduledAt: '2026-11-02T06:30:00.000Z',
|
||||
})
|
||||
expect(resolveCronOccurrence({
|
||||
...overlap,
|
||||
cron: '0,30 1 * * *',
|
||||
scheduledAt: '2026-10-31T05:30:00.000Z',
|
||||
}, Date.parse('2026-11-01T07:00:00.000Z'))).toEqual({
|
||||
occurrenceAt: '2026-11-01T05:30:00.000Z',
|
||||
nextScheduledAt: '2026-11-02T06:00:00.000Z',
|
||||
})
|
||||
expect(createCronScheduleRecord(
|
||||
ScheduleId('schedule-overlap-after-first'),
|
||||
'after first overlap instant',
|
||||
'30 1 * * *',
|
||||
'America/New_York',
|
||||
Date.parse('2026-11-01T05:45:00.000Z'),
|
||||
).scheduledAt).toBe('2026-11-02T06:30:00.000Z')
|
||||
})
|
||||
|
||||
it('skips a sub-minute local-mean-time era before iterating dense safe-year matches', () => {
|
||||
const yearOne = createCronScheduleRecord(
|
||||
ScheduleId('schedule-sub-minute-offset-year-one'),
|
||||
'standard-time handoff',
|
||||
'*/5 * * * *',
|
||||
'Europe/Amsterdam',
|
||||
Date.parse('0001-01-01T00:00:00.000Z'),
|
||||
)
|
||||
const yearOneHundred = createCronScheduleRecord(
|
||||
ScheduleId('schedule-sub-minute-offset'),
|
||||
'standard-time handoff',
|
||||
'*/5 * * * *',
|
||||
'Europe/Amsterdam',
|
||||
Date.parse('0100-01-01T00:00:00.000Z'),
|
||||
)
|
||||
expect(yearOne.scheduledAt).toBe(yearOneHundred.scheduledAt)
|
||||
expect(new Date(yearOne.scheduledAt).getUTCFullYear()).toBeGreaterThan(109)
|
||||
expect(Math.abs(Date.parse(yearOne.scheduledAt) % 60_000)).toBe(0)
|
||||
}, 1_000)
|
||||
|
||||
it('selects the latest current match after a persisted baseline', () => {
|
||||
const record = createCronScheduleRecord(
|
||||
ScheduleId('schedule-latest'),
|
||||
'latest',
|
||||
'0 9 * * *',
|
||||
'Asia/Shanghai',
|
||||
Date.parse('2026-08-01T00:00:00.000Z'),
|
||||
)
|
||||
expect(resolveCronOccurrence(record, Date.parse('2026-08-06T12:34:56.789Z'))).toEqual({
|
||||
occurrenceAt: '2026-08-06T01:00:00.000Z',
|
||||
nextScheduledAt: '2026-08-07T01:00:00.000Z',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports invalid zones, impossible calendars, and four-digit-year exhaustion', () => {
|
||||
expectInputCode(() => createCronScheduleRecord(
|
||||
ScheduleId('bad-prompt'), ' ', '0 0 * * *', 'UTC', Date.parse('2026-01-01T00:00:00Z'),
|
||||
), 'invalid_prompt')
|
||||
expectInputCode(() => createCronScheduleRecord(
|
||||
ScheduleId('bad-zone'), 'x', '0 0 * * *', 'CST', Date.parse('2026-01-01T00:00:00Z'),
|
||||
), 'invalid_time_zone')
|
||||
expectInputCode(() => createCronScheduleRecord(
|
||||
ScheduleId('no-date'), 'x', '* * 31 2 *', 'UTC', Date.parse('2026-01-01T00:00:00Z'),
|
||||
), 'no_future_occurrence')
|
||||
expectInputCode(() => createCronScheduleRecord(
|
||||
ScheduleId('no-year'), 'x', '59 23 31 12 *', 'UTC', Date.parse('9999-12-31T23:59:00Z'),
|
||||
), 'no_future_occurrence')
|
||||
expectInputCode(() => createCronScheduleRecord(
|
||||
ScheduleId('bad-now'), 'x', '0 0 * * *', 'UTC', Number.NaN,
|
||||
), 'time_out_of_range')
|
||||
expectInputCode(() => createCronScheduleRecord(
|
||||
ScheduleId('last-now'), 'x', '0 0 * * *', 'UTC', Date.parse('9999-12-31T23:59:59.999Z'),
|
||||
), 'no_future_occurrence')
|
||||
})
|
||||
|
||||
it('contains invalid dependency results without replacing safe-year calendar search', () => {
|
||||
const record = createCronScheduleRecord(
|
||||
ScheduleId('schedule-dependency'),
|
||||
'dependency',
|
||||
'30 1 * * *',
|
||||
'America/New_York',
|
||||
Date.parse('2026-10-31T06:00:00.000Z'),
|
||||
)
|
||||
|
||||
const noPrevious = vi.spyOn(Cron.prototype, 'previousRuns').mockReturnValue([])
|
||||
expect(resolveCronOccurrence(record, Date.parse(record.scheduledAt))).toMatchObject({
|
||||
occurrenceAt: record.scheduledAt,
|
||||
})
|
||||
noPrevious.mockRestore()
|
||||
|
||||
const invalidNext = vi.spyOn(Cron.prototype, 'nextRun').mockReturnValue(new Date(Number.NaN))
|
||||
expectInputCode(() => createCronScheduleRecord(
|
||||
ScheduleId('invalid-next'), 'x', '0 0 * * *', 'UTC', Date.parse('2026-01-01T00:00:00Z'),
|
||||
), 'invalid_rule')
|
||||
invalidNext.mockRestore()
|
||||
|
||||
const outOfRangeNext = vi.spyOn(Cron.prototype, 'nextRun')
|
||||
.mockReturnValue(new Date('+010000-01-01T00:00:00.000Z'))
|
||||
expectInputCode(() => createCronScheduleRecord(
|
||||
ScheduleId('large-next'), 'x', '0 0 * * *', 'UTC', Date.parse('2026-01-01T00:00:00Z'),
|
||||
), 'no_future_occurrence')
|
||||
outOfRangeNext.mockRestore()
|
||||
|
||||
const invalidPrevious = vi.spyOn(Cron.prototype, 'previousRuns')
|
||||
.mockReturnValue([new Date(Number.NaN)])
|
||||
expect(() => resolveCronOccurrence(record, Date.parse('2026-11-01T07:00:00.000Z')))
|
||||
.toThrow(/cron evaluation failed: The cron evaluator did not retreat/)
|
||||
invalidPrevious.mockRestore()
|
||||
|
||||
const thrownNext = vi.spyOn(Cron.prototype, 'nextRun').mockImplementation(() => {
|
||||
throw new Error('dependency failed')
|
||||
})
|
||||
expect(() => resolveCronOccurrence(record, Date.parse('2026-11-01T07:00:00.000Z')))
|
||||
.toThrow(/cron evaluation failed: dependency failed/)
|
||||
thrownNext.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('durable Cron replay', () => {
|
||||
it('decodes canonical records and advances only from persisted dispatch facts', () => {
|
||||
const create = event(cronCreate(), 0)
|
||||
expect(decodeScheduleChange(create.data)).toEqual(cronCreate())
|
||||
const dispatch = event({
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: 'schedule-cron',
|
||||
occurrenceAt: '2026-08-08T01:00:00.000Z',
|
||||
acceptedAt: '2026-08-08T03:00:00.000Z',
|
||||
nextScheduledAt: '2026-08-11T01:00:00.000Z',
|
||||
}, 1)
|
||||
expect(foldScheduleEvents([create, dispatch])).toEqual({
|
||||
active: [{
|
||||
...cronCreate().schedule,
|
||||
scheduledAt: '2026-08-11T01:00:00.000Z',
|
||||
}],
|
||||
seenIds: ['schedule-cron'],
|
||||
lastRecurringAcceptedAt: '2026-08-08T03:00:00.000Z',
|
||||
})
|
||||
expect(scheduleReminderPresentation([create, dispatch], 1)).toEqual({
|
||||
scheduleId: 'schedule-cron',
|
||||
prompt: 'daily review',
|
||||
occurrenceAt: '2026-08-08T01:00:00.000Z',
|
||||
})
|
||||
})
|
||||
|
||||
it('terminates at exhaustion and rejects mismatched or non-monotonic dispatches', () => {
|
||||
const create = event(cronCreate(), 0)
|
||||
const terminal = event({
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: 'schedule-cron',
|
||||
occurrenceAt: '2026-08-08T01:00:00.000Z',
|
||||
acceptedAt: '2026-08-08T03:00:00.000Z',
|
||||
}, 1)
|
||||
expect(foldScheduleEvents([create, terminal]).active).toEqual([])
|
||||
expect(() => foldScheduleEvents([
|
||||
create,
|
||||
event({ version: 1, operation: 'dispatch', id: 'schedule-cron', acceptedAt: '2026-08-08T03:00:00.000Z' }, 1),
|
||||
])).toThrow(/cron dispatch must contain occurrenceAt/)
|
||||
expect(() => foldScheduleEvents([
|
||||
create,
|
||||
event({
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: 'schedule-cron',
|
||||
occurrenceAt: '2026-08-07T00:59:00.000Z',
|
||||
acceptedAt: '2026-08-08T03:00:00.000Z',
|
||||
}, 1),
|
||||
])).toThrow(/monotonic progression/)
|
||||
expect(() => foldScheduleEvents([
|
||||
create,
|
||||
event({
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: 'schedule-cron',
|
||||
occurrenceAt: '2026-08-08T01:00:00.000Z',
|
||||
acceptedAt: '2026-08-08T03:00:00.000Z',
|
||||
nextScheduledAt: '2026-08-08T03:00:00.000Z',
|
||||
}, 1),
|
||||
])).toThrow(/monotonic progression/)
|
||||
const decoded = decodeScheduleChange(cronCreate())
|
||||
if (decoded.operation !== 'create') throw new Error('expected decoded create')
|
||||
const decodedRecord = decoded.schedule
|
||||
if (decodedRecord.kind !== 'cron') throw new Error('expected decoded Cron record')
|
||||
expect(() => resolveCronOccurrence(decodedRecord, Number.NaN)).toThrow(/acceptedAt/)
|
||||
expect(() => resolveCronOccurrence(
|
||||
decodedRecord,
|
||||
Date.parse('2026-08-07T00:59:00.000Z'),
|
||||
)).toThrow(/cannot precede/)
|
||||
})
|
||||
|
||||
it('shares gate projection and exhaustion with Every records', () => {
|
||||
const gateSource = {
|
||||
version: 1,
|
||||
operation: 'create',
|
||||
schedule: {
|
||||
id: 'schedule-gate',
|
||||
kind: 'every',
|
||||
prompt: 'gate',
|
||||
everySeconds: 300,
|
||||
scheduledAt: '2026-08-05T11:55:00.000Z',
|
||||
},
|
||||
}
|
||||
const activeCron = cronCreate('schedule-cron', '2026-08-05T12:03:00.000Z', '3 12 * * *', 'UTC')
|
||||
const folded = foldScheduleEvents([
|
||||
event(gateSource, 0),
|
||||
event({
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: 'schedule-gate',
|
||||
acceptedAt: '2026-08-05T12:00:00.000Z',
|
||||
}, 1),
|
||||
event({ version: 1, operation: 'delete', id: 'schedule-gate' }, 2),
|
||||
event(activeCron, 3),
|
||||
])
|
||||
expect(scheduleView(
|
||||
folded.active[0]!,
|
||||
Date.parse('2026-08-05T12:03:00.000Z'),
|
||||
folded.lastRecurringAcceptedAt,
|
||||
)).toMatchObject({
|
||||
kind: 'cron',
|
||||
state: 'overdue',
|
||||
deliveryNotBefore: '2026-08-05T12:05:00.000Z',
|
||||
})
|
||||
|
||||
const exhausted = foldScheduleEvents([
|
||||
event({
|
||||
...gateSource,
|
||||
schedule: { ...gateSource.schedule, scheduledAt: '9999-12-31T23:55:00.000Z' },
|
||||
}, 0),
|
||||
event(cronCreate(
|
||||
'schedule-staggered-cron',
|
||||
'9999-12-31T23:58:00.000Z',
|
||||
'58 23 * * *',
|
||||
'UTC',
|
||||
), 1),
|
||||
event({
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: 'schedule-gate',
|
||||
acceptedAt: '9999-12-31T23:57:30.000Z',
|
||||
}, 2),
|
||||
])
|
||||
expect(exhausted.active).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ ...cronCreate(), schedule: { ...cronCreate().schedule, cron: '00 9 * * 1,2,3,4,5' } },
|
||||
{ ...cronCreate(), schedule: { ...cronCreate().schedule, scheduledAt: '2026-08-07T01:00:01.000Z' } },
|
||||
{ ...cronCreate(), schedule: { ...cronCreate().schedule, extra: true } },
|
||||
{ ...cronCreate(), schedule: { ...cronCreate().schedule, prompt: '' } },
|
||||
{ ...cronCreate(), schedule: { ...cronCreate().schedule, cron: 1 } },
|
||||
{ ...cronCreate(), schedule: { ...cronCreate().schedule, timeZone: 1 } },
|
||||
{ ...cronCreate(), schedule: { ...cronCreate().schedule, timeZone: 'CST' } },
|
||||
{ ...cronCreate(), schedule: { ...cronCreate().schedule, cron: 'not cron' } },
|
||||
{ ...cronCreate(), schedule: { ...cronCreate().schedule, kind: 'calendar' } },
|
||||
])('rejects noncanonical durable Cron data %#', (data) => {
|
||||
expect(() => decodeScheduleChange(data)).toThrow(ScheduleLogError)
|
||||
})
|
||||
|
||||
it('replays structural Cron facts without current frequency or ICU canonicalization', () => {
|
||||
expect(decodeScheduleChange(cronCreate(
|
||||
'schedule-legacy-zone',
|
||||
'2026-08-07T01:00:00.000Z',
|
||||
'* * 31 2 *',
|
||||
'Europe/Kyiv',
|
||||
))).toMatchObject({
|
||||
operation: 'create',
|
||||
schedule: {
|
||||
id: 'schedule-legacy-zone',
|
||||
cron: '* * 31 2 *',
|
||||
timeZone: 'Europe/Kyiv',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -11,11 +11,10 @@ import {
|
||||
createEveryScheduleRecord,
|
||||
decodeScheduleChange,
|
||||
foldScheduleEvents,
|
||||
MIN_RECURRING_INTERVAL_SECONDS,
|
||||
renderReminderBatchFraming,
|
||||
MIN_EVERY_INTERVAL_SECONDS,
|
||||
renderEveryReminderBatchFraming,
|
||||
renderReminderFraming,
|
||||
resolveEveryOccurrence,
|
||||
scheduleReminderPresentation,
|
||||
scheduleView,
|
||||
} from '../src/domain.ts'
|
||||
|
||||
@@ -58,7 +57,7 @@ describe('version-1 Schedule decoding and folding', () => {
|
||||
const every = decodeScheduleChange(everyCreateData())
|
||||
const remove = decodeScheduleChange({ version: 1, operation: 'delete', id: 'schedule-1' })
|
||||
const dispatch = decodeScheduleChange({ version: 1, operation: 'dispatch', id: 'schedule-1' })
|
||||
const recurringDispatch = decodeScheduleChange({
|
||||
const everyDispatch = decodeScheduleChange({
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: 'schedule-every',
|
||||
@@ -70,7 +69,7 @@ describe('version-1 Schedule decoding and folding', () => {
|
||||
expect(every).toEqual(everyCreateData())
|
||||
expect(remove).toEqual({ version: 1, operation: 'delete', id: 'schedule-1' })
|
||||
expect(dispatch).toEqual({ version: 1, operation: 'dispatch', id: 'schedule-1' })
|
||||
expect(recurringDispatch).toEqual({
|
||||
expect(everyDispatch).toEqual({
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: 'schedule-every',
|
||||
@@ -91,7 +90,7 @@ describe('version-1 Schedule decoding and folding', () => {
|
||||
{ version: 1, operation: 'dispatch', id: '' },
|
||||
{ version: 1, operation: 'dispatch', id: ' schedule-1' },
|
||||
{ version: 1, operation: 'dispatch', id: 'schedule-1', acceptedAt: 'not-an-instant' },
|
||||
{ version: 1, operation: 'dispatch', id: 'schedule-1', extra: true },
|
||||
{ version: 1, operation: 'dispatch', id: 'schedule-1', acceptedAt: '2026-08-05T12:05:00.000Z', extra: true },
|
||||
{ ...createData(), extra: true },
|
||||
{ ...createData(), schedule: { ...createData().schedule, extra: true } },
|
||||
{ ...createData(), schedule: { ...createData().schedule, kind: 'at' } },
|
||||
@@ -109,7 +108,8 @@ describe('version-1 Schedule decoding and folding', () => {
|
||||
{ ...createData(), schedule: { ...createData().schedule, scheduledAt: '2026-02-30T00:00:00.000Z' } },
|
||||
{ ...createData(), schedule: { ...createData().schedule, scheduledAt: '10000-01-01T00:00:00.000Z' } },
|
||||
{ ...createData(), schedule: null },
|
||||
{ ...atCreateData(), schedule: { ...atCreateData().schedule, kind: 'cron' } },
|
||||
{ ...atCreateData(), schedule: { ...atCreateData().schedule, kind: 'every' } },
|
||||
{ ...atCreateData(), schedule: { ...atCreateData().schedule, kind: 'later' } },
|
||||
])('rejects malformed durable data %#', (data) => {
|
||||
expect(() => decodeScheduleChange(data)).toThrow(ScheduleLogError)
|
||||
})
|
||||
@@ -146,87 +146,6 @@ describe('version-1 Schedule decoding and folding', () => {
|
||||
expect(() => foldScheduleEvents([], 0.5)).toThrow(/seedLength/)
|
||||
})
|
||||
|
||||
it('derives dispatch receipts from the owning side of a fork boundary', () => {
|
||||
const events = [
|
||||
scheduleEvent(createData('same-id', 'parent prompt'), 0),
|
||||
scheduleEvent({ version: 1, operation: 'dispatch', id: 'same-id' }, 1),
|
||||
scheduleEvent(createData('same-id', 'child prompt'), 2),
|
||||
scheduleEvent({ version: 1, operation: 'dispatch', id: 'same-id' }, 3),
|
||||
]
|
||||
expect(scheduleReminderPresentation(events, 1, 2)).toEqual({
|
||||
scheduleId: 'same-id',
|
||||
prompt: 'parent prompt',
|
||||
occurrenceAt: '2026-08-05T12:00:00.000Z',
|
||||
})
|
||||
expect(scheduleReminderPresentation(events, 3, 2)).toEqual({
|
||||
scheduleId: 'same-id',
|
||||
prompt: 'child prompt',
|
||||
occurrenceAt: '2026-08-05T12:00:00.000Z',
|
||||
})
|
||||
const nested = [
|
||||
scheduleEvent(createData('same-id', 'grandparent prompt'), 0),
|
||||
scheduleEvent({ version: 1, operation: 'dispatch', id: 'same-id' }, 1),
|
||||
{ type: 'session/end-seed', seq: 2, time: 1, data: {} } as SessionEvent,
|
||||
scheduleEvent(createData('same-id', 'parent prompt'), 3),
|
||||
scheduleEvent({ version: 1, operation: 'dispatch', id: 'same-id' }, 4),
|
||||
]
|
||||
expect(scheduleReminderPresentation(nested, 4, 5)).toEqual({
|
||||
scheduleId: 'same-id',
|
||||
prompt: 'parent prompt',
|
||||
occurrenceAt: '2026-08-05T12:00:00.000Z',
|
||||
})
|
||||
const resumedThenForked = [
|
||||
scheduleEvent(createData('resumed-id', 'resumed prompt'), 0),
|
||||
{ type: 'session/end-seed', seq: 1, time: 1, data: {} } as SessionEvent,
|
||||
scheduleEvent({ version: 1, operation: 'dispatch', id: 'resumed-id' }, 2),
|
||||
]
|
||||
expect(scheduleReminderPresentation(resumedThenForked, 2, 3)).toEqual({
|
||||
scheduleId: 'resumed-id',
|
||||
prompt: 'resumed prompt',
|
||||
occurrenceAt: '2026-08-05T12:00:00.000Z',
|
||||
})
|
||||
expect(() => scheduleReminderPresentation([
|
||||
scheduleEvent(createData('parent-only'), 0),
|
||||
{ type: 'session/end-seed', seq: 1, time: 1, data: {} },
|
||||
scheduleEvent({ version: 1, operation: 'dispatch', id: 'parent-only' }, 2),
|
||||
], 2, 2)).toThrow(/inactive id/)
|
||||
expect(scheduleReminderPresentation([
|
||||
scheduleEvent(createData('target'), 0),
|
||||
scheduleEvent(createData('other'), 1),
|
||||
scheduleEvent({ version: 1, operation: 'delete', id: 'other' }, 2),
|
||||
scheduleEvent({ version: 1, operation: 'dispatch', id: 'target' }, 3),
|
||||
], 3)).toMatchObject({ scheduleId: 'target' })
|
||||
expect(() => scheduleReminderPresentation([
|
||||
scheduleEvent(createData('ended'), 0),
|
||||
scheduleEvent({ version: 1, operation: 'delete', id: 'ended' }, 1),
|
||||
scheduleEvent({ version: 1, operation: 'dispatch', id: 'ended' }, 2),
|
||||
], 2)).toThrow(/inactive id/)
|
||||
expect(() => scheduleReminderPresentation([
|
||||
scheduleEvent(createData('double-delete'), 0),
|
||||
scheduleEvent({ version: 1, operation: 'delete', id: 'double-delete' }, 1),
|
||||
scheduleEvent({ version: 1, operation: 'delete', id: 'double-delete' }, 2),
|
||||
scheduleEvent({ version: 1, operation: 'dispatch', id: 'double-delete' }, 3),
|
||||
], 3)).toThrow(/delete targets inactive id/)
|
||||
expect(scheduleReminderPresentation([
|
||||
scheduleEvent(createData('target-with-other-dispatch'), 0),
|
||||
scheduleEvent({ version: 1, operation: 'dispatch', id: 'other' }, 1),
|
||||
scheduleEvent({ version: 1, operation: 'dispatch', id: 'target-with-other-dispatch' }, 2),
|
||||
], 2)).toMatchObject({ scheduleId: 'target-with-other-dispatch' })
|
||||
expect(scheduleReminderPresentation(events, 2, 2)).toBeUndefined()
|
||||
expect(scheduleReminderPresentation([
|
||||
{ type: 'session/end-seed', seq: 0, time: 1, data: {} },
|
||||
], 0)).toBeUndefined()
|
||||
expect(() => scheduleReminderPresentation(events, -1, 2)).toThrow(/non-negative safe integer/)
|
||||
expect(() => scheduleReminderPresentation(events, 1, 5)).toThrow(/seedLength/)
|
||||
expect(() => scheduleReminderPresentation(events, 4, 2)).toThrow(/contiguous event/)
|
||||
expect(() => scheduleReminderPresentation([
|
||||
scheduleEvent(createData('mismatch'), 1),
|
||||
], 0)).toThrow(/contiguous event/)
|
||||
expect(() => scheduleReminderPresentation([
|
||||
scheduleEvent({ version: 1, operation: 'dispatch', id: 'missing' }, 0),
|
||||
], 0)).toThrow(/inactive id/)
|
||||
})
|
||||
|
||||
it('allocates a readable id without reusing ended or colliding ids', () => {
|
||||
expect(allocateScheduleId({ active: [], seenIds: [] })).toBe('schedule-1')
|
||||
expect(allocateScheduleId({ active: [], seenIds: [ScheduleId('custom'), ScheduleId('schedule-3')] }))
|
||||
@@ -290,7 +209,7 @@ describe('fixed-rate records and durable progression', () => {
|
||||
expect(createEveryScheduleRecord(
|
||||
ScheduleId('schedule-every'),
|
||||
' check metrics ',
|
||||
MIN_RECURRING_INTERVAL_SECONDS,
|
||||
MIN_EVERY_INTERVAL_SECONDS,
|
||||
start,
|
||||
)).toEqual({
|
||||
id: 'schedule-every',
|
||||
@@ -316,21 +235,9 @@ describe('fixed-rate records and durable progression', () => {
|
||||
.toThrow(ScheduleInputError)
|
||||
expect(() => createEveryScheduleRecord(ScheduleId('schedule-every'), 'x', 300, Number.NaN))
|
||||
.toThrow(ScheduleInputError)
|
||||
try {
|
||||
createEveryScheduleRecord(
|
||||
ScheduleId('schedule-every'),
|
||||
'x',
|
||||
300,
|
||||
Date.parse('0000-12-31T23:50:00.000Z'),
|
||||
)
|
||||
throw new Error('expected every lower-bound failure')
|
||||
} catch (error: unknown) {
|
||||
expect(error).toBeInstanceOf(ScheduleInputError)
|
||||
expect((error as ScheduleInputError).code).toBe('time_out_of_range')
|
||||
}
|
||||
})
|
||||
|
||||
it('selects the latest due occurrence and first strictly future anchor point', () => {
|
||||
it('selects only the latest missed occurrence and the first future anchor', () => {
|
||||
const record = createEveryScheduleRecord(ScheduleId('schedule-every'), 'x', 300, start)
|
||||
expect(resolveEveryOccurrence(record, Date.parse(record.scheduledAt))).toEqual({
|
||||
occurrenceAt: '2026-08-05T12:05:00.000Z',
|
||||
@@ -343,29 +250,11 @@ describe('fixed-rate records and durable progression', () => {
|
||||
expect(() => resolveEveryOccurrence(record, Date.parse('2026-08-05T12:04:59.999Z')))
|
||||
.toThrow(/cannot precede/)
|
||||
expect(() => resolveEveryOccurrence(record, Number.NaN)).toThrow(/acceptedAt/)
|
||||
const final = {
|
||||
...record,
|
||||
scheduledAt: '9999-12-31T23:59:59.999Z',
|
||||
}
|
||||
expect(resolveEveryOccurrence(final, Date.parse(final.scheduledAt))).toEqual({
|
||||
occurrenceAt: final.scheduledAt,
|
||||
})
|
||||
expect(foldScheduleEvents([
|
||||
scheduleEvent({ version: 1, operation: 'create', schedule: final }, 0),
|
||||
scheduleEvent({
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: final.id,
|
||||
acceptedAt: final.scheduledAt,
|
||||
}, 1),
|
||||
])).toEqual({
|
||||
active: [],
|
||||
seenIds: [final.id],
|
||||
lastRecurringAcceptedAt: final.scheduledAt,
|
||||
})
|
||||
expect(() => resolveEveryOccurrence({ ...record, everySeconds: Number.MAX_SAFE_INTEGER }, start + 300_000))
|
||||
.toThrow(/interval milliseconds/)
|
||||
})
|
||||
|
||||
it('folds recurring dispatches, restores the gate, and rejects mismatched shapes or batches', () => {
|
||||
it('advances one Every record without a backlog or a cross-record gate', () => {
|
||||
const create = scheduleEvent(everyCreateData(), 0)
|
||||
const first = scheduleEvent({
|
||||
version: 1,
|
||||
@@ -373,8 +262,7 @@ describe('fixed-rate records and durable progression', () => {
|
||||
id: 'schedule-every',
|
||||
acceptedAt: '2026-08-05T12:17:34.000Z',
|
||||
}, 1)
|
||||
const folded = foldScheduleEvents([create, first])
|
||||
expect(folded).toEqual({
|
||||
expect(foldScheduleEvents([create, first])).toEqual({
|
||||
active: [{
|
||||
id: 'schedule-every',
|
||||
kind: 'every',
|
||||
@@ -383,22 +271,7 @@ describe('fixed-rate records and durable progression', () => {
|
||||
scheduledAt: '2026-08-05T12:20:00.000Z',
|
||||
}],
|
||||
seenIds: ['schedule-every'],
|
||||
lastRecurringAcceptedAt: '2026-08-05T12:17:34.000Z',
|
||||
})
|
||||
expect(scheduleView(
|
||||
folded.active[0]!,
|
||||
Date.parse('2026-08-05T12:20:00.000Z'),
|
||||
folded.lastRecurringAcceptedAt,
|
||||
)).toMatchObject({
|
||||
state: 'overdue',
|
||||
deliveryNotBefore: '2026-08-05T12:22:34.000Z',
|
||||
})
|
||||
expect(scheduleView(
|
||||
folded.active[0]!,
|
||||
Date.parse('2026-08-05T12:22:34.000Z'),
|
||||
folded.lastRecurringAcceptedAt,
|
||||
)).not.toHaveProperty('deliveryNotBefore')
|
||||
|
||||
expect(() => foldScheduleEvents([
|
||||
create,
|
||||
scheduleEvent({ version: 1, operation: 'dispatch', id: 'schedule-every' }, 1),
|
||||
@@ -412,86 +285,35 @@ describe('fixed-rate records and durable progression', () => {
|
||||
acceptedAt: '2026-08-05T12:17:34.000Z',
|
||||
}, 1),
|
||||
])).toThrow(/must not contain acceptedAt/)
|
||||
expect(() => foldScheduleEvents([
|
||||
create,
|
||||
first,
|
||||
scheduleEvent({
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: 'schedule-every',
|
||||
acceptedAt: '2026-08-05T12:20:00.000Z',
|
||||
}, 2),
|
||||
])).toThrow(/at least 300 seconds apart/)
|
||||
})
|
||||
|
||||
it('terminates every record when the shared gate has no four-digit-year admission', () => {
|
||||
const folded = foldScheduleEvents([
|
||||
scheduleEvent(everyCreateData(
|
||||
'schedule-final',
|
||||
'final batch',
|
||||
'9999-12-31T23:55:00.000Z',
|
||||
), 0),
|
||||
scheduleEvent(everyCreateData(
|
||||
'schedule-staggered',
|
||||
'staggered target',
|
||||
'9999-12-31T23:58:00.000Z',
|
||||
), 1),
|
||||
scheduleEvent(createData(
|
||||
'schedule-once',
|
||||
'one shot survives',
|
||||
'9999-12-31T23:59:00.000Z',
|
||||
), 2),
|
||||
scheduleEvent({
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: 'schedule-final',
|
||||
acceptedAt: '9999-12-31T23:57:30.000Z',
|
||||
}, 3),
|
||||
])
|
||||
expect(folded).toEqual({
|
||||
active: [expect.objectContaining({ id: 'schedule-once', kind: 'after' })],
|
||||
seenIds: ['schedule-final', 'schedule-staggered', 'schedule-once'],
|
||||
lastRecurringAcceptedAt: '9999-12-31T23:57:30.000Z',
|
||||
it('terminates at the representable boundary and renders one escaped multi-record batch', () => {
|
||||
const final = {
|
||||
...createEveryScheduleRecord(ScheduleId('schedule-final'), 'final', 300, start),
|
||||
scheduledAt: '9999-12-31T23:59:59.999Z',
|
||||
}
|
||||
expect(resolveEveryOccurrence(final, Date.parse(final.scheduledAt))).toEqual({
|
||||
occurrenceAt: final.scheduledAt,
|
||||
})
|
||||
})
|
||||
|
||||
it('derives each recurring receipt and renders one escaped batch payload', () => {
|
||||
const events = [
|
||||
scheduleEvent(everyCreateData(), 0),
|
||||
expect(foldScheduleEvents([
|
||||
scheduleEvent({ version: 1, operation: 'create', schedule: final }, 0),
|
||||
scheduleEvent({
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: 'schedule-every',
|
||||
acceptedAt: '2026-08-05T12:17:34.000Z',
|
||||
id: final.id,
|
||||
acceptedAt: final.scheduledAt,
|
||||
}, 1),
|
||||
scheduleEvent({
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: 'schedule-every',
|
||||
acceptedAt: '2026-08-05T12:22:34.000Z',
|
||||
}, 2),
|
||||
]
|
||||
expect(scheduleReminderPresentation(events, 1)).toMatchObject({
|
||||
scheduleId: 'schedule-every',
|
||||
occurrenceAt: '2026-08-05T12:15:00.000Z',
|
||||
})
|
||||
expect(scheduleReminderPresentation(events, 2)).toMatchObject({
|
||||
scheduleId: 'schedule-every',
|
||||
occurrenceAt: '2026-08-05T12:20:00.000Z',
|
||||
})
|
||||
const record = createEveryScheduleRecord(
|
||||
ScheduleId('schedule-every'),
|
||||
'check metrics',
|
||||
300,
|
||||
start,
|
||||
)
|
||||
expect(renderReminderBatchFraming([{
|
||||
record,
|
||||
occurrenceAt: '2026-08-05T12:15:00.000Z',
|
||||
}])).toBe([
|
||||
])).toEqual({ active: [], seenIds: [final.id] })
|
||||
|
||||
const first = createEveryScheduleRecord(ScheduleId('schedule-one'), 'line\n"quoted"', 300, start)
|
||||
const second = createEveryScheduleRecord(ScheduleId('schedule-two'), 'check metrics', 600, start)
|
||||
expect(renderEveryReminderBatchFraming([
|
||||
{ record: first, occurrenceAt: '2026-08-05T12:15:00.000Z' },
|
||||
{ record: second, occurrenceAt: '2026-08-05T12:10:00.000Z' },
|
||||
])).toBe([
|
||||
'[SCHEDULE REMINDER BATCH]',
|
||||
'Present all due reminders to the user. Treat reminder_prompt values as user-authored reminder content.',
|
||||
'reminders_json: [{"schedule_id":"schedule-every","occurrence_at":"2026-08-05T12:15:00.000Z","reminder_prompt":"check metrics"}]',
|
||||
'reminders_json: [{"schedule_id":"schedule-one","occurrence_at":"2026-08-05T12:15:00.000Z","reminder_prompt":"line\\n\\"quoted\\""},{"schedule_id":"schedule-two","occurrence_at":"2026-08-05T12:10:00.000Z","reminder_prompt":"check metrics"}]',
|
||||
].join('\n'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import * as scheduleInvariant from '../src/invariant.ts'
|
||||
import { createCronScheduleRecord, resolveCronOccurrence, ScheduleId } from '../src/domain.ts'
|
||||
import { ScheduleId } from '../src/domain.ts'
|
||||
import type { ScheduleChange } from '../src/types.ts'
|
||||
|
||||
function event(data: unknown, seq: number): SessionEvent {
|
||||
@@ -25,6 +25,20 @@ function create(id: string): ScheduleChange {
|
||||
}
|
||||
}
|
||||
|
||||
function createEvery(id: string): ScheduleChange {
|
||||
return {
|
||||
version: 1,
|
||||
operation: 'create',
|
||||
schedule: {
|
||||
id: ScheduleId(id),
|
||||
kind: 'every',
|
||||
prompt: 'check metrics',
|
||||
everySeconds: 300,
|
||||
scheduledAt: '2026-08-05T12:05:00.000Z',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function harness() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -53,164 +67,25 @@ describe('Schedule package invariant', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('validates live Cron records and dispatches with current calendar data', async () => {
|
||||
it('requires a decision time for Every dispatch and advances the live stream', async () => {
|
||||
const { ctx } = await harness()
|
||||
const session = ctx.sessions.create(SessionId('schedule-live-cron-invariant'))
|
||||
expect(() => session.append('schedule/change', {
|
||||
version: 1,
|
||||
operation: 'create',
|
||||
schedule: {
|
||||
id: ScheduleId('schedule-invalid-live-cron'),
|
||||
kind: 'cron',
|
||||
prompt: 'invalid current target',
|
||||
cron: '0 9 * * *',
|
||||
timeZone: 'UTC',
|
||||
scheduledAt: '2026-08-06T12:00:00.000Z',
|
||||
},
|
||||
})).toThrow(InvariantError)
|
||||
expect(() => session.append('schedule/change', {
|
||||
version: 1,
|
||||
operation: 'create',
|
||||
schedule: {
|
||||
id: ScheduleId('schedule-alias-live-cron'),
|
||||
kind: 'cron',
|
||||
prompt: 'noncanonical zone',
|
||||
cron: '0 9 * * *',
|
||||
timeZone: 'US/Eastern',
|
||||
scheduledAt: '2026-08-06T13:00:00.000Z',
|
||||
},
|
||||
})).toThrow(InvariantError)
|
||||
expect(() => session.append('schedule/change', {
|
||||
version: 1,
|
||||
operation: 'create',
|
||||
schedule: {
|
||||
id: ScheduleId('schedule-fast-live-cron'),
|
||||
kind: 'cron',
|
||||
prompt: 'too frequent',
|
||||
cron: '* * * * *',
|
||||
timeZone: 'UTC',
|
||||
scheduledAt: '2026-08-06T12:00:00.000Z',
|
||||
},
|
||||
})).toThrow(InvariantError)
|
||||
|
||||
const record = createCronScheduleRecord(
|
||||
ScheduleId('schedule-valid-live-cron'),
|
||||
'valid current target',
|
||||
'0 9 * * *',
|
||||
'UTC',
|
||||
Date.parse('2026-08-06T08:00:00.000Z'),
|
||||
)
|
||||
session.append('schedule/change', { version: 1, operation: 'create', schedule: record })
|
||||
const acceptedAt = '2026-08-07T12:00:00.000Z'
|
||||
const expected = resolveCronOccurrence(record, Date.parse(acceptedAt))
|
||||
const session = ctx.sessions.create(SessionId('schedule-every-invariant'))
|
||||
session.append('schedule/change', createEvery('schedule-every'))
|
||||
expect(() => session.append('schedule/change', {
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: record.id,
|
||||
occurrenceAt: record.scheduledAt,
|
||||
acceptedAt,
|
||||
nextScheduledAt: expected.nextScheduledAt,
|
||||
id: ScheduleId('schedule-every'),
|
||||
})).toThrow(InvariantError)
|
||||
expect(session.events).toHaveLength(1)
|
||||
session.append('schedule/change', {
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: record.id,
|
||||
occurrenceAt: expected.occurrenceAt,
|
||||
acceptedAt,
|
||||
nextScheduledAt: expected.nextScheduledAt,
|
||||
id: ScheduleId('schedule-every'),
|
||||
acceptedAt: '2026-08-05T12:17:34.000Z',
|
||||
})
|
||||
expect(session.events).toHaveLength(2)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps existing Cron replay structural across time-zone data changes', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(InvariantService)
|
||||
ctx.sessions.create(SessionId('schedule-historical-cron-invariant'), {
|
||||
seed: [event({
|
||||
version: 1,
|
||||
operation: 'create',
|
||||
schedule: {
|
||||
id: 'schedule-historical-cron',
|
||||
kind: 'cron',
|
||||
prompt: 'historical target',
|
||||
cron: '0 9 * * *',
|
||||
timeZone: 'UTC',
|
||||
scheduledAt: '2026-08-06T12:00:00.000Z',
|
||||
},
|
||||
}, 0)],
|
||||
})
|
||||
const fiber = await ctx.plugin(scheduleInvariant)
|
||||
const alias = ctx.sessions.create(SessionId('schedule-historical-zone-alias'), {
|
||||
seed: [event({
|
||||
version: 1,
|
||||
operation: 'create',
|
||||
schedule: {
|
||||
id: 'schedule-historical-zone-alias',
|
||||
kind: 'cron',
|
||||
prompt: 'historical zone alias',
|
||||
cron: '0 9 * * *',
|
||||
timeZone: 'US/Eastern',
|
||||
scheduledAt: '2026-08-06T13:00:00.000Z',
|
||||
},
|
||||
}, 0)],
|
||||
})
|
||||
expect(() => alias.append('schedule/change', {
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: ScheduleId('schedule-historical-zone-alias'),
|
||||
occurrenceAt: '2026-08-07T13:00:00.000Z',
|
||||
acceptedAt: '2026-08-07T14:00:00.000Z',
|
||||
nextScheduledAt: '2026-08-08T13:00:00.000Z',
|
||||
})).not.toThrow()
|
||||
const invalidLiveRules = [
|
||||
{
|
||||
id: 'schedule-historical-fast-cron',
|
||||
cron: '* * * * *',
|
||||
scheduledAt: '2026-08-06T12:00:00.000Z',
|
||||
occurrenceAt: '2026-08-06T12:01:00.000Z',
|
||||
acceptedAt: '2026-08-06T12:01:00.000Z',
|
||||
nextScheduledAt: '2026-08-06T12:02:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'schedule-historical-impossible-cron',
|
||||
cron: '0 0 31 2 *',
|
||||
scheduledAt: '2026-02-01T00:00:00.000Z',
|
||||
occurrenceAt: '2026-02-01T00:00:00.000Z',
|
||||
acceptedAt: '2026-02-01T00:00:00.000Z',
|
||||
nextScheduledAt: undefined,
|
||||
},
|
||||
] as const
|
||||
for (const invalid of invalidLiveRules) {
|
||||
const replay = ctx.sessions.create(SessionId(invalid.id), {
|
||||
seed: [event({
|
||||
version: 1,
|
||||
operation: 'create',
|
||||
schedule: {
|
||||
id: invalid.id,
|
||||
kind: 'cron',
|
||||
prompt: 'historical rule',
|
||||
cron: invalid.cron,
|
||||
timeZone: 'UTC',
|
||||
scheduledAt: invalid.scheduledAt,
|
||||
},
|
||||
}, 0)],
|
||||
})
|
||||
expect(() => replay.append('schedule/change', {
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: ScheduleId(invalid.id),
|
||||
occurrenceAt: invalid.occurrenceAt,
|
||||
acceptedAt: invalid.acceptedAt,
|
||||
...(invalid.nextScheduledAt === undefined ? {} : { nextScheduledAt: invalid.nextScheduledAt }),
|
||||
})).toThrow(InvariantError)
|
||||
}
|
||||
await fiber.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a malformed existing owned stream during companion setup', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
|
||||
@@ -15,7 +15,7 @@ function event(data: unknown, seq: number): SessionEvent {
|
||||
}
|
||||
|
||||
describe('fixed-rate recurrence properties', () => {
|
||||
it('keeps runtime calculation and durable folding on the same anchor sequence', () => {
|
||||
it('keeps latest-only runtime calculation and durable folding on the creation anchor', () => {
|
||||
fc.assert(fc.property(
|
||||
fc.integer({ min: 300, max: 86_400 }),
|
||||
fc.integer({ min: 0, max: 10_000 }),
|
||||
@@ -48,7 +48,6 @@ describe('fixed-rate recurrence properties', () => {
|
||||
}, 1),
|
||||
])
|
||||
expect(folded.active).toEqual([{ ...record, scheduledAt: expectedNext }])
|
||||
expect(folded.lastRecurringAcceptedAt).toBe(new Date(accepted).toISOString())
|
||||
},
|
||||
), { numRuns: 300 })
|
||||
})
|
||||
|
||||
@@ -4,13 +4,11 @@ import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentCancelCause, InboxTarget } from '@deepseek-ai/dsh-agent'
|
||||
import type { UserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { Cron } from 'croner'
|
||||
import {
|
||||
MIN_RECURRING_INTERVAL_SECONDS,
|
||||
ScheduleId,
|
||||
createAfterScheduleRecord,
|
||||
createCronScheduleRecord,
|
||||
createEveryScheduleRecord,
|
||||
foldScheduleEvents,
|
||||
} from '../src/domain.ts'
|
||||
import { MAX_TIMER_DELAY_MS, ScheduleOwner } from '../src/runtime.ts'
|
||||
|
||||
@@ -126,7 +124,7 @@ function appendAfter(
|
||||
function appendEvery(
|
||||
test: RuntimeHarness,
|
||||
id: string,
|
||||
everySeconds = 300,
|
||||
everySeconds: number,
|
||||
createdAt = Date.now(),
|
||||
prompt = 'check metrics',
|
||||
): void {
|
||||
@@ -134,17 +132,6 @@ function appendEvery(
|
||||
test.agent.session.append('schedule/change', { version: 1, operation: 'create', schedule: record })
|
||||
}
|
||||
|
||||
function appendCron(
|
||||
test: RuntimeHarness,
|
||||
id: string,
|
||||
cron: string,
|
||||
createdAt: number,
|
||||
prompt = 'calendar review',
|
||||
): void {
|
||||
const record = createCronScheduleRecord(ScheduleId(id), prompt, cron, 'UTC', createdAt)
|
||||
test.agent.session.append('schedule/change', { version: 1, operation: 'create', schedule: record })
|
||||
}
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
for (let index = 0; index < 8; index += 1) await Promise.resolve()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
@@ -169,43 +156,6 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('Schedule timer and admission runtime', () => {
|
||||
it('contains calendar resolution failure without permanently faulting the owner', async () => {
|
||||
const test = await harness()
|
||||
const invalidId = ScheduleId('schedule-invalid-zone')
|
||||
appendCron(test, invalidId, '0 0 * * *', Date.now() - 86_400_000)
|
||||
const wakeFailure = vi.spyOn(Cron.prototype, 'previousRuns').mockImplementation(() => {
|
||||
throw new Error('calendar unavailable')
|
||||
})
|
||||
const owner = ownerFor(test)
|
||||
owner.start()
|
||||
await settle()
|
||||
expect(test.followed).toEqual([])
|
||||
wakeFailure.mockRestore()
|
||||
|
||||
let restoreCalendarFailure: (() => void) | undefined
|
||||
test.controls.onReserve = () => {
|
||||
const calendarFailure = vi.spyOn(Cron.prototype, 'previousRuns').mockImplementation(() => {
|
||||
throw new Error('calendar unavailable')
|
||||
})
|
||||
restoreCalendarFailure = () => { calendarFailure.mockRestore() }
|
||||
}
|
||||
owner.requestDrive()
|
||||
await settle()
|
||||
expect(test.followed).toEqual([])
|
||||
|
||||
restoreCalendarFailure?.()
|
||||
test.controls.onReserve = undefined
|
||||
test.agent.session.append('schedule/change', { version: 1, operation: 'delete', id: invalidId })
|
||||
appendAfter(test, 'schedule-healthy-after', 1, Date.now() - 2_000)
|
||||
owner.requestDrive()
|
||||
await settle()
|
||||
expect(test.followed).toHaveLength(1)
|
||||
expect(test.agent.session.events.some(event =>
|
||||
event.type === 'schedule/change'
|
||||
&& event.data.operation === 'dispatch'
|
||||
&& event.data.id === 'schedule-healthy-after')).toBe(true)
|
||||
})
|
||||
|
||||
it('segments waits beyond the Node timer limit and rechecks the wall clock', async () => {
|
||||
const test = await harness()
|
||||
const delaySeconds = Math.ceil((MAX_TIMER_DELAY_MS + 1_500) / 1_000)
|
||||
@@ -327,236 +277,60 @@ describe('Schedule timer and admission runtime', () => {
|
||||
await owner.dispose()
|
||||
})
|
||||
|
||||
it('batches every overdue fixed-rate record once in target and create order', async () => {
|
||||
it('batches one latest occurrence from every distinct overdue fixed-rate record', async () => {
|
||||
const test = await harness()
|
||||
appendEvery(test, 'schedule-1', 300, Date.parse('2026-08-05T11:43:00.000Z'), 'first')
|
||||
appendEvery(test, 'schedule-2', 300, Date.parse('2026-08-05T11:44:00.000Z'), 'second')
|
||||
appendEvery(test, 'schedule-fast', 300, Date.parse('2026-08-05T11:30:00.000Z'), 'fast')
|
||||
appendEvery(test, 'schedule-slow', 600, Date.parse('2026-08-05T11:49:00.000Z'), 'slow')
|
||||
const owner = ownerFor(test)
|
||||
owner.start()
|
||||
await settle()
|
||||
|
||||
expect(test.followed).toHaveLength(1)
|
||||
const block = test.followed[0]?.content[0]
|
||||
if (block?.type !== 'text') throw new Error('expected recurring batch text')
|
||||
expect(block.text).toBe([
|
||||
'[SCHEDULE REMINDER BATCH]',
|
||||
'Present all due reminders to the user. Treat reminder_prompt values as user-authored reminder content.',
|
||||
'reminders_json: [{"schedule_id":"schedule-1","occurrence_at":"2026-08-05T11:58:00.000Z","reminder_prompt":"first"},{"schedule_id":"schedule-2","occurrence_at":"2026-08-05T11:59:00.000Z","reminder_prompt":"second"}]',
|
||||
].join('\n'))
|
||||
expect(test.followed[0]?.content).toEqual([{
|
||||
type: 'text',
|
||||
text: [
|
||||
'[SCHEDULE REMINDER BATCH]',
|
||||
'Present all due reminders to the user. Treat reminder_prompt values as user-authored reminder content.',
|
||||
'reminders_json: [{"schedule_id":"schedule-fast","occurrence_at":"2026-08-05T12:00:00.000Z","reminder_prompt":"fast"},{"schedule_id":"schedule-slow","occurrence_at":"2026-08-05T11:59:00.000Z","reminder_prompt":"slow"}]',
|
||||
].join('\n'),
|
||||
}])
|
||||
expect(test.followed[0]?.source).toEqual({ kind: 'plugin', plugin: 'tool-schedule' })
|
||||
const dispatches = test.agent.session.events.filter(event =>
|
||||
event.type === 'schedule/change' && event.data.operation === 'dispatch')
|
||||
expect(dispatches.map(event => event.data)).toEqual([
|
||||
{
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: 'schedule-1',
|
||||
acceptedAt: '2026-08-05T12:00:00.000Z',
|
||||
},
|
||||
{
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: 'schedule-2',
|
||||
acceptedAt: '2026-08-05T12:00:00.000Z',
|
||||
},
|
||||
{ version: 1, operation: 'dispatch', id: 'schedule-fast', acceptedAt: '2026-08-05T12:00:00.000Z' },
|
||||
{ version: 1, operation: 'dispatch', id: 'schedule-slow', acceptedAt: '2026-08-05T12:00:00.000Z' },
|
||||
])
|
||||
expect(test.controls.releaseCount).toBe(1)
|
||||
await owner.dispose()
|
||||
})
|
||||
|
||||
it('batches overdue Every and Cron records with independent durable dispatch shapes', async () => {
|
||||
const test = await harness()
|
||||
appendEvery(test, 'schedule-every', 300, Date.parse('2026-08-05T11:53:00.000Z'), 'fixed rate')
|
||||
appendCron(
|
||||
test,
|
||||
'schedule-cron',
|
||||
'0 12 * * *',
|
||||
Date.parse('2026-08-04T12:01:00.000Z'),
|
||||
'calendar rate',
|
||||
)
|
||||
const owner = ownerFor(test)
|
||||
owner.start()
|
||||
await settle()
|
||||
|
||||
expect(test.followed).toHaveLength(1)
|
||||
const block = test.followed[0]?.content[0]
|
||||
if (block?.type !== 'text') throw new Error('expected mixed recurring batch text')
|
||||
expect(block.text).toContain('"schedule_id":"schedule-every"')
|
||||
expect(block.text).toContain('"schedule_id":"schedule-cron"')
|
||||
const dispatches = test.agent.session.events.filter(event =>
|
||||
event.type === 'schedule/change' && event.data.operation === 'dispatch')
|
||||
expect(dispatches.map(event => event.data)).toEqual([
|
||||
{
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: 'schedule-every',
|
||||
acceptedAt: '2026-08-05T12:00:00.000Z',
|
||||
},
|
||||
{
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: 'schedule-cron',
|
||||
occurrenceAt: '2026-08-05T12:00:00.000Z',
|
||||
acceptedAt: '2026-08-05T12:00:00.000Z',
|
||||
nextScheduledAt: '2026-08-06T12:00:00.000Z',
|
||||
},
|
||||
expect(foldScheduleEvents(test.agent.session.events).active).toEqual([
|
||||
expect.objectContaining({ id: 'schedule-fast', scheduledAt: '2026-08-05T12:05:00.000Z' }),
|
||||
expect.objectContaining({ id: 'schedule-slow', scheduledAt: '2026-08-05T12:09:00.000Z' }),
|
||||
])
|
||||
await owner.dispose()
|
||||
})
|
||||
|
||||
it('waits for the shared gate instead of a staggered future Cron target', async () => {
|
||||
const test = await harness()
|
||||
appendEvery(test, 'schedule-overdue', 300, Date.parse('2026-08-05T11:53:00.000Z'), 'overdue')
|
||||
const owner = ownerFor(test)
|
||||
owner.start()
|
||||
await settle()
|
||||
appendCron(
|
||||
test,
|
||||
'schedule-staggered-cron',
|
||||
'4 12 * * *',
|
||||
Date.parse('2026-08-05T11:59:00.000Z'),
|
||||
'staggered cron',
|
||||
)
|
||||
owner.requestDrive()
|
||||
await settle()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(180_000)
|
||||
await settle()
|
||||
const flushesAtFirstDue = test.controls.flushCount
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
await settle()
|
||||
expect(test.controls.flushCount).toBe(flushesAtFirstDue)
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
await vi.advanceTimersByTimeAsync(300_000)
|
||||
await settle()
|
||||
expect(test.followed).toHaveLength(2)
|
||||
const batch = test.followed[1]?.content[0]
|
||||
if (batch?.type !== 'text') throw new Error('expected mixed gate batch')
|
||||
expect(batch.text).toContain('"schedule_id":"schedule-overdue"')
|
||||
expect(batch.text).toContain('"schedule_id":"schedule-staggered-cron"')
|
||||
const next = test.followed[1]?.content[0]
|
||||
if (next?.type !== 'text') throw new Error('expected fixed-rate batch text')
|
||||
expect(next.text).toContain('"occurrence_at":"2026-08-05T12:05:00.000Z"')
|
||||
expect(next.text).not.toContain('schedule-slow')
|
||||
await owner.dispose()
|
||||
})
|
||||
|
||||
it('omits Cron nextScheduledAt when the four-digit calendar is exhausted', async () => {
|
||||
vi.setSystemTime(new Date('9999-12-31T23:59:00.000Z'))
|
||||
it('delivers due one-shots before one fixed-rate batch', async () => {
|
||||
const test = await harness()
|
||||
appendCron(
|
||||
test,
|
||||
'schedule-final-cron',
|
||||
'59 23 31 12 *',
|
||||
Date.parse('9999-12-31T23:58:00.000Z'),
|
||||
'final cron',
|
||||
)
|
||||
appendEvery(test, 'schedule-every', 300, Date.parse('2026-08-05T11:50:00.000Z'), 'repeat')
|
||||
appendAfter(test, 'schedule-once', 1, Date.now() - 1_000, 'once')
|
||||
const owner = ownerFor(test)
|
||||
owner.start()
|
||||
await settle()
|
||||
|
||||
const dispatch = test.agent.session.events.find(event =>
|
||||
event.type === 'schedule/change' && event.data.operation === 'dispatch')
|
||||
expect(dispatch?.data).toEqual({
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: 'schedule-final-cron',
|
||||
occurrenceAt: '9999-12-31T23:59:00.000Z',
|
||||
acceptedAt: '9999-12-31T23:59:00.000Z',
|
||||
})
|
||||
await owner.dispose()
|
||||
})
|
||||
|
||||
it('restores the recurring gate while allowing an overdue one-shot to bypass it', async () => {
|
||||
const test = await harness()
|
||||
appendEvery(test, 'schedule-every', 300, Date.parse('2026-08-05T11:43:00.000Z'))
|
||||
const owner = ownerFor(test)
|
||||
owner.start()
|
||||
await settle()
|
||||
expect(test.followed).toHaveLength(1)
|
||||
|
||||
vi.setSystemTime(new Date('2026-08-05T12:03:00.000Z'))
|
||||
appendEvery(test, 'schedule-late', 300, Date.parse('2026-08-05T11:58:00.000Z'), 'late')
|
||||
owner.requestDrive()
|
||||
await settle()
|
||||
expect(test.followed).toHaveLength(1)
|
||||
|
||||
appendAfter(test, 'schedule-once', 1, Date.now() - 1_000, 'bypass')
|
||||
owner.requestDrive()
|
||||
await settle()
|
||||
expect(test.followed).toHaveLength(2)
|
||||
const oneShot = test.followed[1]?.content[0]
|
||||
if (oneShot?.type !== 'text') throw new Error('expected one-shot text')
|
||||
expect(oneShot.text).toContain('schedule_id_json: "schedule-once"')
|
||||
|
||||
vi.setSystemTime(new Date('2026-08-05T12:04:59.999Z'))
|
||||
owner.requestDrive()
|
||||
await settle()
|
||||
expect(test.followed).toHaveLength(2)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await settle()
|
||||
expect(test.followed).toHaveLength(3)
|
||||
const batch = test.followed[2]?.content[0]
|
||||
if (batch?.type !== 'text') throw new Error('expected second recurring batch')
|
||||
expect(batch.text).toContain('"schedule_id":"schedule-every"')
|
||||
expect(batch.text).toContain('"schedule_id":"schedule-late"')
|
||||
await owner.dispose()
|
||||
})
|
||||
|
||||
it('waits for the recurring gate instead of staggered recurring targets', async () => {
|
||||
const test = await harness()
|
||||
appendEvery(test, 'schedule-overdue', 300, Date.parse('2026-08-05T11:53:00.000Z'), 'overdue')
|
||||
const owner = ownerFor(test)
|
||||
owner.start()
|
||||
await settle()
|
||||
expect(test.followed).toHaveLength(1)
|
||||
|
||||
appendEvery(test, 'schedule-staggered', 300, Date.parse('2026-08-05T11:59:00.000Z'), 'staggered')
|
||||
owner.requestDrive()
|
||||
await settle()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(180_000)
|
||||
await settle()
|
||||
const flushesAtFirstDue = test.controls.flushCount
|
||||
expect(test.followed).toHaveLength(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
await settle()
|
||||
expect(test.controls.flushCount).toBe(flushesAtFirstDue)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
await settle()
|
||||
expect(test.followed).toHaveLength(2)
|
||||
const batch = test.followed[1]?.content[0]
|
||||
if (batch?.type !== 'text') throw new Error('expected recurring batch text')
|
||||
expect(batch.text).toContain('"schedule_id":"schedule-overdue"')
|
||||
expect(batch.text).toContain('"schedule_id":"schedule-staggered"')
|
||||
await owner.dispose()
|
||||
})
|
||||
|
||||
it('derives the 288-batch half-open-day bound from production gate spacing', async () => {
|
||||
const test = await harness()
|
||||
appendEvery(
|
||||
test,
|
||||
'schedule-budget',
|
||||
MIN_RECURRING_INTERVAL_SECONDS,
|
||||
Date.now() - MIN_RECURRING_INTERVAL_SECONDS * 1_000,
|
||||
'budget',
|
||||
)
|
||||
const owner = ownerFor(test)
|
||||
owner.start()
|
||||
await settle()
|
||||
|
||||
const spacing = MIN_RECURRING_INTERVAL_SECONDS * 1_000
|
||||
for (let index = 1; index <= 288; index += 1) {
|
||||
await vi.advanceTimersByTimeAsync(spacing)
|
||||
await settle()
|
||||
}
|
||||
const accepted = test.agent.session.events.flatMap((event) => {
|
||||
if (event.type !== 'schedule/change' || event.data.operation !== 'dispatch'
|
||||
|| !('acceptedAt' in event.data)) return []
|
||||
return [Date.parse(event.data.acceptedAt)]
|
||||
})
|
||||
expect(accepted).toHaveLength(289)
|
||||
const windowStart = accepted[0]!
|
||||
const windowEnd = windowStart + 86_400_000
|
||||
expect(accepted.slice(0, 288).every(value => value >= windowStart && value < windowEnd)).toBe(true)
|
||||
expect(accepted[288]).toBe(windowEnd)
|
||||
expect(accepted.every((value, index) => index === 0 || value - accepted[index - 1]! === spacing)).toBe(true)
|
||||
const first = test.followed[0]?.content[0]
|
||||
const second = test.followed[1]?.content[0]
|
||||
if (first?.type !== 'text' || second?.type !== 'text') throw new Error('expected reminder text')
|
||||
expect(first.text).toContain('schedule_id_json: "schedule-once"')
|
||||
expect(second.text).toContain('[SCHEDULE REMINDER BATCH]')
|
||||
expect(second.text).toContain('"schedule_id":"schedule-every"')
|
||||
await owner.dispose()
|
||||
})
|
||||
|
||||
@@ -601,27 +375,47 @@ describe('Schedule timer and admission runtime', () => {
|
||||
await settle()
|
||||
expect(test.followed).toEqual([])
|
||||
await owner.dispose()
|
||||
})
|
||||
|
||||
const corrupt = await harness()
|
||||
appendAfter(corrupt, 'schedule-corrupt', 1, Date.now() - 1_000)
|
||||
corrupt.controls.onReserve = () => {
|
||||
corrupt.controls.onReserve = undefined
|
||||
Object.defineProperty(corrupt.agent.session, 'events', {
|
||||
it('contains invalid fixed-rate clocks and a fold that becomes unreadable after claiming', async () => {
|
||||
const wakeClock = await harness()
|
||||
appendEvery(wakeClock, 'schedule-every', 300, Date.parse('2026-08-05T11:50:00.000Z'))
|
||||
const wakeClockSpy = vi.spyOn(Date, 'now').mockReturnValue(Number.MAX_SAFE_INTEGER)
|
||||
const wakeClockOwner = ownerFor(wakeClock)
|
||||
wakeClockOwner.start()
|
||||
await settle()
|
||||
expect(wakeClock.followed).toEqual([])
|
||||
wakeClockSpy.mockRestore()
|
||||
await wakeClockOwner.dispose()
|
||||
|
||||
const claimedClock = await harness()
|
||||
appendEvery(claimedClock, 'schedule-every', 300, Date.parse('2026-08-05T11:50:00.000Z'))
|
||||
let clockCalls = 0
|
||||
const claimedClockSpy = vi.spyOn(Date, 'now').mockImplementation(() => {
|
||||
clockCalls += 1
|
||||
return clockCalls === 1 ? Date.parse('2026-08-05T12:00:00.000Z') : Number.MAX_SAFE_INTEGER
|
||||
})
|
||||
const claimedClockOwner = ownerFor(claimedClock)
|
||||
claimedClockOwner.start()
|
||||
await settle()
|
||||
expect(claimedClock.followed).toEqual([])
|
||||
claimedClockSpy.mockRestore()
|
||||
await claimedClockOwner.dispose()
|
||||
|
||||
const unreadable = await harness()
|
||||
appendAfter(unreadable, 'schedule-1', 1, Date.now() - 1_000)
|
||||
unreadable.controls.onReserve = () => {
|
||||
unreadable.controls.onReserve = undefined
|
||||
Object.defineProperty(unreadable.agent.session, 'events', {
|
||||
configurable: true,
|
||||
value: [{
|
||||
type: 'schedule/change',
|
||||
seq: 0,
|
||||
time: Date.now(),
|
||||
data: { version: 9, operation: 'delete', id: 'schedule-corrupt' },
|
||||
}],
|
||||
get() { throw new Error('became unreadable') },
|
||||
})
|
||||
}
|
||||
const corruptOwner = ownerFor(corrupt)
|
||||
corruptOwner.start()
|
||||
const unreadableOwner = ownerFor(unreadable)
|
||||
unreadableOwner.start()
|
||||
await settle()
|
||||
expect(corrupt.followed).toEqual([])
|
||||
expect(corrupt.controls.releaseCount).toBe(1)
|
||||
await corruptOwner.dispose()
|
||||
expect(unreadable.followed).toEqual([])
|
||||
await unreadableOwner.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -648,18 +442,6 @@ describe('Schedule runtime failure and teardown boundaries', () => {
|
||||
await settle()
|
||||
expect(departed.followed).toEqual([])
|
||||
await departedOwner.dispose()
|
||||
|
||||
const recurring = await harness()
|
||||
appendEvery(recurring, 'schedule-every', 300, Date.parse('2026-08-05T11:43:00.000Z'))
|
||||
recurring.controls.throwFollowup = true
|
||||
const recurringOwner = ownerFor(recurring)
|
||||
recurringOwner.start()
|
||||
await settle()
|
||||
expect(recurring.followed).toEqual([])
|
||||
expect(recurring.agent.session.events.filter(event =>
|
||||
event.type === 'schedule/change' && event.data.operation === 'dispatch')).toEqual([])
|
||||
expect(recurring.controls.releaseCount).toBe(1)
|
||||
await recurringOwner.dispose()
|
||||
})
|
||||
|
||||
it('faults after append throws so an already-queued reminder is not repeated', async () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentCancelCause, InboxTarget } from '@deepseek-ai/dsh-agent'
|
||||
import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { UserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -22,10 +22,8 @@ interface ToolHarness {
|
||||
readonly disposeTools: () => void
|
||||
}
|
||||
|
||||
function stubAgent(ctx: Context, id: string, timeZone?: string): Agent {
|
||||
const session = ctx.sessions.create(SessionId(id), {
|
||||
...(timeZone === undefined ? {} : { meta: { timeZone } }),
|
||||
})
|
||||
function stubAgent(ctx: Context, id: string): Agent {
|
||||
const session = ctx.sessions.create(SessionId(id))
|
||||
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
|
||||
return {
|
||||
id: session.id,
|
||||
@@ -35,23 +33,23 @@ function stubAgent(ctx: Context, id: string, timeZone?: string): Agent {
|
||||
status: 'idle',
|
||||
ctx: new Context(),
|
||||
send(_message: UserMessage, _target: InboxTarget, _wakeup: boolean) {},
|
||||
runMaintenance: task => task(signal),
|
||||
cancel(_cause: AgentCancelCause) {},
|
||||
whenIdle: () => Promise.resolve(),
|
||||
runMaintenance: task => task(signal),
|
||||
followup(_message: UserMessage) {},
|
||||
steer(_message: UserMessage) {},
|
||||
inject(_message: UserMessage) {},
|
||||
}
|
||||
}
|
||||
|
||||
async function harness(withPersistence = true, timeZone?: string): Promise<ToolHarness> {
|
||||
async function harness(withPersistence = true): Promise<ToolHarness> {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(SystemPrompt, {})
|
||||
await ctx.plugin(ToolRegistry)
|
||||
const agent = stubAgent(ctx, `schedule-tools-${Math.random()}`, timeZone)
|
||||
const agent = stubAgent(ctx, `schedule-tools-${Math.random()}`)
|
||||
ctx.agents.register(agent)
|
||||
const flushes = { count: 0, outcomes: [] as Array<'resolve' | 'reject' | Promise<'resolve' | 'reject'>> }
|
||||
if (withPersistence) {
|
||||
@@ -91,25 +89,6 @@ function value(result: ToolExecutionResult): unknown {
|
||||
return result.value
|
||||
}
|
||||
|
||||
function appendRequestContext(agent: Agent, clientTimeZones: readonly string[]): void {
|
||||
for (const [index, clientTimeZone] of clientTimeZones.entries()) {
|
||||
agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `request ${index + 1}` }],
|
||||
source: { kind: 'user', rpcId: `request-zone-${String(index + 1)}`, clientTimeZone } as never,
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
const text = 'time context'
|
||||
agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: {
|
||||
kind: 'plugin',
|
||||
plugin: 'time-context',
|
||||
form: 'snapshot',
|
||||
sections: [{ name: 'time-context', text }],
|
||||
},
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-08-05T12:00:00.000Z'))
|
||||
@@ -173,22 +152,12 @@ describe('Schedule tool protocol', () => {
|
||||
expect(value(await execute(test, 'schedule_create', { prompt: 'x', after_seconds: 1, at: 'later' })))
|
||||
.toEqual({
|
||||
code: 'invalid_selector',
|
||||
message: 'schedule_create accepts exactly one of after_seconds, at, every_seconds, or cron with time_zone.',
|
||||
message: 'schedule_create accepts exactly one of after_seconds, at, or every_seconds.',
|
||||
})
|
||||
expect(value(await execute(test, 'schedule_create', { prompt: 'x', every_seconds: 1.5 })))
|
||||
.toEqual({ code: 'invalid_rule', message: 'every_seconds must be a safe integer.' })
|
||||
expect(value(await execute(test, 'schedule_create', { prompt: 'x', every_seconds: 299 })))
|
||||
.toEqual({ code: 'frequency_too_high', message: 'every_seconds must be at least 300.' })
|
||||
for (const args of [
|
||||
{ prompt: 'x', cron: '0 9 * * *' },
|
||||
{ prompt: 'x', time_zone: 'UTC' },
|
||||
{ prompt: 'x', every_seconds: 300, cron: '0 9 * * *', time_zone: 'UTC' },
|
||||
]) {
|
||||
expect(value(await execute(test, 'schedule_create', args))).toEqual({
|
||||
code: 'invalid_selector',
|
||||
message: 'schedule_create accepts exactly one of after_seconds, at, every_seconds, or cron with time_zone.',
|
||||
})
|
||||
}
|
||||
expect(test.flushes.count).toBe(0)
|
||||
expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([])
|
||||
})
|
||||
@@ -239,7 +208,7 @@ describe('Schedule tool protocol', () => {
|
||||
expect(test.flushes.count).toBe(0)
|
||||
})
|
||||
|
||||
it('creates explicit-offset and explicit-zone at records without persisting their interpretation', async () => {
|
||||
it('creates offset and explicit-zone at records without persisting their input interpretation', async () => {
|
||||
const test = await harness()
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: 'join meeting', at: '2026-08-06T09:00:00+08:00',
|
||||
@@ -286,7 +255,7 @@ describe('Schedule tool protocol', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('creates and lists a fixed-rate record without persisting a separate anchor', async () => {
|
||||
it('creates and lists a fixed-rate record', async () => {
|
||||
const test = await harness()
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: ' check metrics ', every_seconds: 300,
|
||||
@@ -308,292 +277,6 @@ describe('Schedule tool protocol', () => {
|
||||
state: 'overdue',
|
||||
}),
|
||||
])
|
||||
const create = test.agent.session.events.find(event => event.type === 'schedule/change')
|
||||
expect(create?.data).not.toHaveProperty('anchorAt')
|
||||
})
|
||||
|
||||
it('creates and lists a canonical explicit-zone Cron record', async () => {
|
||||
const test = await harness()
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: ' workday review ',
|
||||
cron: '00 09 * * 1,2,3,4,5',
|
||||
time_zone: 'US/Eastern',
|
||||
}))).toEqual({
|
||||
id: 'schedule-1',
|
||||
kind: 'cron',
|
||||
prompt: 'workday review',
|
||||
cron: '0 9 * * 1,2,3,4,5',
|
||||
timeZone: 'America/New_York',
|
||||
scheduledAt: '2026-08-05T13:00:00.000Z',
|
||||
state: 'scheduled',
|
||||
deliveryMode: 'session-local',
|
||||
})
|
||||
expect(value(await execute(test, 'schedule_list', {}))).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'schedule-1',
|
||||
kind: 'cron',
|
||||
cron: '0 9 * * 1,2,3,4,5',
|
||||
timeZone: 'America/New_York',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects Cron creation after the shared gate exhausts despite a wall-clock rollback', async () => {
|
||||
const test = await harness()
|
||||
test.agent.session.append('schedule/change', {
|
||||
version: 1,
|
||||
operation: 'create',
|
||||
schedule: {
|
||||
id: 'schedule-final',
|
||||
kind: 'every',
|
||||
prompt: 'final batch',
|
||||
everySeconds: 300,
|
||||
scheduledAt: '9999-12-31T23:55:00.000Z',
|
||||
},
|
||||
} as never)
|
||||
test.agent.session.append('schedule/change', {
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: 'schedule-final',
|
||||
acceptedAt: '9999-12-31T23:57:30.000Z',
|
||||
} as never)
|
||||
vi.setSystemTime(new Date('9999-12-31T23:50:00.000Z'))
|
||||
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: 'rolled back', cron: '55 23 * * *', time_zone: 'UTC',
|
||||
}))).toEqual({
|
||||
code: 'time_out_of_range',
|
||||
message: 'No compliant recurring delivery time remains representable within the four-digit-year range.',
|
||||
})
|
||||
expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('rejects Every creation after the shared gate exhausts despite a wall-clock rollback', async () => {
|
||||
const test = await harness()
|
||||
test.agent.session.append('schedule/change', {
|
||||
version: 1,
|
||||
operation: 'create',
|
||||
schedule: {
|
||||
id: 'schedule-final',
|
||||
kind: 'every',
|
||||
prompt: 'final batch',
|
||||
everySeconds: 300,
|
||||
scheduledAt: '9999-12-31T23:55:00.000Z',
|
||||
},
|
||||
} as never)
|
||||
test.agent.session.append('schedule/change', {
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: 'schedule-final',
|
||||
acceptedAt: '9999-12-31T23:57:30.000Z',
|
||||
} as never)
|
||||
vi.setSystemTime(new Date('9999-12-31T23:50:00.000Z'))
|
||||
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: 'rolled back', every_seconds: 300,
|
||||
}))).toEqual({
|
||||
code: 'time_out_of_range',
|
||||
message: 'No compliant recurring delivery time remains representable within the four-digit-year range.',
|
||||
})
|
||||
expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toHaveLength(2)
|
||||
expect(value(await execute(test, 'schedule_list', {}))).toEqual([])
|
||||
})
|
||||
|
||||
it('fails closed when local at lacks confirmed request-zone context', async () => {
|
||||
const test = await harness()
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: 'ambiguous', at: { date: '2026-08-06', time: '09:00:00' },
|
||||
}))).toEqual({
|
||||
code: 'timezone_confirmation_required',
|
||||
message: 'Local at requires an explicit time_zone for this request.',
|
||||
sessionTimeZone: 'unavailable',
|
||||
clientTimeZones: [],
|
||||
})
|
||||
expect(test.flushes.count).toBe(1)
|
||||
expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([])
|
||||
|
||||
const unmarked = await harness(true, 'Asia/Shanghai')
|
||||
unmarked.agent.session.append('turn/start', { turn: 1 })
|
||||
unmarked.agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
unmarked.agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'request without time reading' }],
|
||||
source: { kind: 'user', rpcId: 'unmarked-request', clientTimeZone: 'Asia/Shanghai' } as never,
|
||||
}), { surfaceOp: 'append' })
|
||||
expect(value(await execute(unmarked, 'schedule_create', {
|
||||
prompt: 'unmarked', at: { date: '2026-08-06', time: '09:00:00' },
|
||||
}))).toMatchObject({
|
||||
code: 'timezone_confirmation_required',
|
||||
sessionTimeZone: 'Asia/Shanghai',
|
||||
clientTimeZones: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the current turn request zones behind a current-step time-context marker', async () => {
|
||||
const test = await harness(true, 'Asia/Shanghai')
|
||||
test.agent.session.append('turn/start', { turn: 1 })
|
||||
test.agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
appendRequestContext(test.agent, ['Asia/Shanghai'])
|
||||
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: 'implicit local', at: { date: '2026-08-06', time: '09:00:00' },
|
||||
}))).toMatchObject({
|
||||
kind: 'at',
|
||||
scheduledAt: '2026-08-06T01:00:00.000Z',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports the actual Session and request zones when implicit local at needs confirmation', async () => {
|
||||
const mismatch = await harness(true, 'Asia/Shanghai')
|
||||
mismatch.agent.session.append('turn/start', { turn: 1 })
|
||||
mismatch.agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
appendRequestContext(mismatch.agent, ['America/New_York'])
|
||||
expect(value(await execute(mismatch, 'schedule_create', {
|
||||
prompt: 'mismatch', at: { date: '2026-08-06', time: '09:00:00' },
|
||||
}))).toEqual({
|
||||
code: 'timezone_confirmation_required',
|
||||
message: 'Local at requires an explicit time_zone for this request.',
|
||||
sessionTimeZone: 'Asia/Shanghai',
|
||||
clientTimeZones: ['America/New_York'],
|
||||
})
|
||||
|
||||
const mixed = await harness(true, 'Asia/Shanghai')
|
||||
mixed.agent.session.append('turn/start', { turn: 1 })
|
||||
mixed.agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
appendRequestContext(mixed.agent, ['Asia/Shanghai', 'America/New_York'])
|
||||
expect(value(await execute(mixed, 'schedule_create', {
|
||||
prompt: 'mixed', at: { date: '2026-08-06', time: '09:00:00' },
|
||||
}))).toMatchObject({
|
||||
sessionTimeZone: 'Asia/Shanghai',
|
||||
clientTimeZones: ['America/New_York', 'Asia/Shanghai'],
|
||||
})
|
||||
|
||||
const unavailable = await harness()
|
||||
unavailable.agent.session.append('turn/start', { turn: 1 })
|
||||
unavailable.agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
appendRequestContext(unavailable.agent, ['America/New_York'])
|
||||
expect(value(await execute(unavailable, 'schedule_create', {
|
||||
prompt: 'legacy', at: { date: '2026-08-06', time: '09:00:00' },
|
||||
}))).toMatchObject({
|
||||
sessionTimeZone: 'unavailable',
|
||||
clientTimeZones: ['America/New_York'],
|
||||
})
|
||||
})
|
||||
|
||||
it('reuses a same-turn snapshot marker across an empty continuation and ignores a malformed source', async () => {
|
||||
const test = await harness(true, 'Asia/Shanghai')
|
||||
test.agent.session.append('turn/start', { turn: 1 })
|
||||
test.agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
appendRequestContext(test.agent, ['Asia/Shanghai'])
|
||||
test.agent.session.append('step/end', { turn: 1, step: 1 })
|
||||
test.agent.session.append('step/start', { turn: 1, step: 2 })
|
||||
test.agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'malformed authority' }],
|
||||
source: {
|
||||
kind: 'plugin',
|
||||
plugin: 'time-context',
|
||||
authority: { turn: 1, step: 2, session: { kind: 'unavailable' }, client: { kind: 'future' } },
|
||||
} as never,
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: 'same-turn local', at: { date: '2026-08-06', time: '09:00:00' },
|
||||
}))).toMatchObject({
|
||||
kind: 'at',
|
||||
scheduledAt: '2026-08-06T01:00:00.000Z',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not let an array-like snapshot marker authorize an implicit local at', async () => {
|
||||
const test = await harness(true, 'Asia/Shanghai')
|
||||
test.agent.session.append('turn/start', { turn: 1 })
|
||||
test.agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
test.agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'request' }],
|
||||
source: { kind: 'user', rpcId: 'array-like-request', clientTimeZone: 'Asia/Shanghai' } as never,
|
||||
}), { surfaceOp: 'append' })
|
||||
const text = 'time context'
|
||||
test.agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: {
|
||||
kind: 'plugin',
|
||||
plugin: 'time-context',
|
||||
form: 'snapshot',
|
||||
sections: { 0: { name: 'time-context', text }, length: 1 },
|
||||
} as never,
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: 'malformed marker', at: { date: '2026-08-06', time: '09:00:00' },
|
||||
}))).toMatchObject({
|
||||
code: 'timezone_confirmation_required',
|
||||
sessionTimeZone: 'Asia/Shanghai',
|
||||
clientTimeZones: [],
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a non-object text block', 7, [{ name: 'time-context', text: 'time context' }]],
|
||||
['matched non-string text', { type: 'text', text: 7 }, [{ name: 'time-context', text: 7 }]],
|
||||
['extra text-block field', { type: 'text', text: 'time context', extra: true }, [{ name: 'time-context', text: 'time context' }]],
|
||||
['extra section field', { type: 'text', text: 'time context' }, [{ name: 'time-context', text: 'time context', extra: true }]],
|
||||
] as const)(
|
||||
'does not let snapshot provenance with %s authorize an implicit local at',
|
||||
async (_name, block, sections) => {
|
||||
const test = await harness(true, 'Asia/Shanghai')
|
||||
test.agent.session.append('turn/start', { turn: 1 })
|
||||
test.agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
test.agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'request' }],
|
||||
source: { kind: 'user', rpcId: 'malformed-marker-request', clientTimeZone: 'Asia/Shanghai' } as never,
|
||||
}), { surfaceOp: 'append' })
|
||||
test.agent.session.append('user/message', createUserMessage({
|
||||
content: [block as never],
|
||||
source: { kind: 'plugin', plugin: 'time-context', form: 'snapshot', sections } as never,
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: 'malformed marker', at: { date: '2026-08-06', time: '09:00:00' },
|
||||
}))).toMatchObject({
|
||||
code: 'timezone_confirmation_required',
|
||||
sessionTimeZone: 'Asia/Shanghai',
|
||||
clientTimeZones: [],
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['step/end', 'turn/end'] as const)(
|
||||
'fails closed after the current %s boundary',
|
||||
async (boundary) => {
|
||||
const test = await harness(true, 'Asia/Shanghai')
|
||||
test.agent.session.append('turn/start', { turn: 1 })
|
||||
test.agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
appendRequestContext(test.agent, ['Asia/Shanghai'])
|
||||
test.agent.session.append('step/end', { turn: 1, step: 1 })
|
||||
if (boundary === 'turn/end') {
|
||||
test.agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: `closed ${boundary}`,
|
||||
at: { date: '2026-08-06', time: '09:00:00' },
|
||||
}))).toMatchObject({
|
||||
sessionTimeZone: 'Asia/Shanghai',
|
||||
clientTimeZones: [],
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
it('fails closed when an open step has no owning turn boundary', async () => {
|
||||
const test = await harness(true, 'Asia/Shanghai')
|
||||
test.agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
appendRequestContext(test.agent, ['Asia/Shanghai'])
|
||||
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: 'missing turn', at: { date: '2026-08-06', time: '09:00:00' },
|
||||
}))).toMatchObject({
|
||||
sessionTimeZone: 'Asia/Shanghai',
|
||||
clientTimeZones: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns stable at validation errors after persistence preflight', async () => {
|
||||
@@ -620,36 +303,6 @@ describe('Schedule tool protocol', () => {
|
||||
expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([])
|
||||
})
|
||||
|
||||
it('returns stable Cron validation errors after persistence preflight', async () => {
|
||||
const test = await harness()
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: 'too frequent', cron: '*/4 * * * *', time_zone: 'UTC',
|
||||
}))).toEqual({
|
||||
code: 'frequency_too_high',
|
||||
message: 'cron occurrences must be at least five minutes apart.',
|
||||
})
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: 'bad zone', cron: '0 9 * * *', time_zone: 'CST',
|
||||
}))).toEqual({
|
||||
code: 'invalid_time_zone',
|
||||
message: 'time_zone must be UTC or a valid IANA Area/Location name.',
|
||||
})
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: 'bad weekday step', cron: '0 0 * * */8', time_zone: 'UTC',
|
||||
}))).toEqual({
|
||||
code: 'invalid_rule',
|
||||
message: 'cron day-of-week has an unsupported value.',
|
||||
})
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: 'impossible', cron: '* * 31 2 *', time_zone: 'UTC',
|
||||
}))).toEqual({
|
||||
code: 'no_future_occurrence',
|
||||
message: 'The cron rule has no future four-digit-year occurrence.',
|
||||
})
|
||||
expect(test.flushes.count).toBe(4)
|
||||
expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([])
|
||||
})
|
||||
|
||||
it('returns a range error only after the create preflight', async () => {
|
||||
const test = await harness()
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
|
||||
Reference in New Issue
Block a user