feat(schedule): add fixed-rate reminders

This commit is contained in:
pku-xht
2026-08-06 20:37:08 +08:00
committed by Tianyi Cui
parent 1e6e92fb0a
commit ceb0bbd66d
19 changed files with 2173 additions and 528 deletions

View File

@@ -8,9 +8,14 @@ import {
canonicalizeTimeZone,
createAfterScheduleRecord,
createAtScheduleRecord,
createEveryScheduleRecord,
decodeScheduleChange,
foldScheduleEvents,
MIN_RECURRING_INTERVAL_SECONDS,
renderReminderBatchFraming,
renderReminderFraming,
resolveEveryOccurrence,
scheduleReminderPresentation,
scheduleView,
} from '../src/domain.ts'
@@ -34,19 +39,46 @@ function atCreateData(id = 'schedule-at', prompt = 'join meeting', scheduledAt =
}
}
function everyCreateData(
id = 'schedule-every',
prompt = 'check metrics',
scheduledAt = '2026-08-05T12:05:00.000Z',
) {
return {
version: 1,
operation: 'create',
schedule: { id, kind: 'every', prompt, everySeconds: 300, scheduledAt },
}
}
describe('version-1 Schedule decoding and folding', () => {
it('decodes and freezes each exact v1 operation', () => {
const create = decodeScheduleChange(createData())
const at = decodeScheduleChange(atCreateData())
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({
version: 1,
operation: 'dispatch',
id: 'schedule-every',
acceptedAt: '2026-08-05T12:05:00.000Z',
})
expect(create).toEqual(createData())
expect(at).toEqual(atCreateData())
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({
version: 1,
operation: 'dispatch',
id: 'schedule-every',
acceptedAt: '2026-08-05T12:05:00.000Z',
})
expect(Object.isFrozen(create)).toBe(true)
expect(Object.isFrozen(at)).toBe(true)
expect(Object.isFrozen(every)).toBe(true)
if (create.operation !== 'create') throw new Error('expected create')
expect(Object.isFrozen(create.schedule)).toBe(true)
})
@@ -58,18 +90,26 @@ describe('version-1 Schedule decoding and folding', () => {
{ version: 1, operation: 'delete', id: 'schedule-1', extra: true },
{ 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 },
{ ...createData(), extra: true },
{ ...createData(), schedule: { ...createData().schedule, extra: true } },
{ ...createData(), schedule: { ...createData().schedule, kind: 'at' } },
{ ...atCreateData(), schedule: { ...atCreateData().schedule, extra: true } },
{ ...atCreateData(), schedule: { ...atCreateData().schedule, prompt: ' ' } },
{ ...everyCreateData(), schedule: { ...everyCreateData().schedule, extra: true } },
{ ...everyCreateData(), schedule: { ...everyCreateData().schedule, prompt: ' ' } },
{ ...everyCreateData(), schedule: { ...everyCreateData().schedule, everySeconds: 299 } },
{ ...everyCreateData(), schedule: { ...everyCreateData().schedule, everySeconds: 300.5 } },
{ ...everyCreateData(), schedule: { ...everyCreateData().schedule, everySeconds: '300' } },
{ ...everyCreateData(), schedule: { ...everyCreateData().schedule, everySeconds: Number.MAX_SAFE_INTEGER } },
{ ...createData(), schedule: { ...createData().schedule, prompt: ' ' } },
{ ...createData(), schedule: { ...createData().schedule, afterSeconds: 0 } },
{ ...createData(), schedule: { ...createData().schedule, afterSeconds: 1.5 } },
{ ...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: 'every' } },
{ ...atCreateData(), schedule: { ...atCreateData().schedule, kind: 'cron' } },
])('rejects malformed durable data %#', (data) => {
expect(() => decodeScheduleChange(data)).toThrow(ScheduleLogError)
})
@@ -106,6 +146,87 @@ 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')] }))
@@ -162,6 +283,179 @@ describe('after record and model framing', () => {
})
})
describe('fixed-rate records and durable progression', () => {
const start = Date.parse('2026-08-05T12:00:00.000Z')
it('creates the first anchored target and enforces the fixed public lower bound', () => {
expect(createEveryScheduleRecord(
ScheduleId('schedule-every'),
' check metrics ',
MIN_RECURRING_INTERVAL_SECONDS,
start,
)).toEqual({
id: 'schedule-every',
kind: 'every',
prompt: 'check metrics',
everySeconds: 300,
scheduledAt: '2026-08-05T12:05:00.000Z',
})
for (const [seconds, code] of [
[299, 'frequency_too_high'],
[1.5, 'invalid_rule'],
[Number.MAX_SAFE_INTEGER, 'time_out_of_range'],
] as const) {
try {
createEveryScheduleRecord(ScheduleId('schedule-every'), 'x', seconds, start)
throw new Error('expected every input failure')
} catch (error: unknown) {
expect(error).toBeInstanceOf(ScheduleInputError)
expect((error as ScheduleInputError).code).toBe(code)
}
}
expect(() => createEveryScheduleRecord(ScheduleId('schedule-every'), ' ', 300, start))
.toThrow(ScheduleInputError)
expect(() => createEveryScheduleRecord(ScheduleId('schedule-every'), 'x', 300, Number.NaN))
.toThrow(ScheduleInputError)
})
it('selects the latest due occurrence and first strictly future anchor point', () => {
const record = createEveryScheduleRecord(ScheduleId('schedule-every'), 'x', 300, start)
expect(resolveEveryOccurrence(record, Date.parse(record.scheduledAt))).toEqual({
occurrenceAt: '2026-08-05T12:05:00.000Z',
nextScheduledAt: '2026-08-05T12:10:00.000Z',
})
expect(resolveEveryOccurrence(record, Date.parse('2026-08-05T12:17:34.000Z'))).toEqual({
occurrenceAt: '2026-08-05T12:15:00.000Z',
nextScheduledAt: '2026-08-05T12:20:00.000Z',
})
expect(() => resolveEveryOccurrence(record, Date.parse('2026-08-05T12:04:59.999Z')))
.toThrow(/cannot precede/)
expect(() => resolveEveryOccurrence(record, Number.NaN)).toThrow(/acceptedAt/)
expect(() => resolveEveryOccurrence({ ...record, everySeconds: 0 }, Date.parse(record.scheduledAt)))
.toThrow(/interval milliseconds/)
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,
})
})
it('folds recurring dispatches, restores the gate, and rejects mismatched shapes or batches', () => {
const create = scheduleEvent(everyCreateData(), 0)
const first = scheduleEvent({
version: 1,
operation: 'dispatch',
id: 'schedule-every',
acceptedAt: '2026-08-05T12:17:34.000Z',
}, 1)
const folded = foldScheduleEvents([create, first])
expect(folded).toEqual({
active: [{
id: 'schedule-every',
kind: 'every',
prompt: 'check metrics',
everySeconds: 300,
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),
])).toThrow(/must contain acceptedAt/)
expect(() => foldScheduleEvents([
scheduleEvent(createData('one-shot'), 0),
scheduleEvent({
version: 1,
operation: 'dispatch',
id: 'one-shot',
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('derives each recurring receipt and renders one escaped batch payload', () => {
const events = [
scheduleEvent(everyCreateData(), 0),
scheduleEvent({
version: 1,
operation: 'dispatch',
id: 'schedule-every',
acceptedAt: '2026-08-05T12:17:34.000Z',
}, 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([
'[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"}]',
].join('\n'))
})
})
describe('absolute record and time-zone resolution', () => {
const now = Date.parse('2026-08-05T12:00:00.000Z')

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import fc from 'fast-check'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import {
ScheduleId,
createEveryScheduleRecord,
foldScheduleEvents,
resolveEveryOccurrence,
} from '../src/domain.ts'
const BASE = Date.parse('2000-01-01T00:00:00.000Z')
function event(data: unknown, seq: number): SessionEvent {
return { type: 'schedule/change', seq, time: BASE, data } as SessionEvent
}
describe('fixed-rate recurrence properties', () => {
it('keeps runtime calculation and durable folding on the same anchor sequence', () => {
fc.assert(fc.property(
fc.integer({ min: 300, max: 86_400 }),
fc.integer({ min: 0, max: 10_000 }),
fc.nat({ max: 86_399_999 }),
(everySeconds, skipped, rawOffset) => {
const record = createEveryScheduleRecord(
ScheduleId('schedule-property'),
'property reminder',
everySeconds,
BASE,
)
const interval = everySeconds * 1_000
const target = Date.parse(record.scheduledAt)
const accepted = target + skipped * interval + rawOffset % interval
const calculated = resolveEveryOccurrence(record, accepted)
const expectedOccurrence = new Date(target + skipped * interval).toISOString()
const expectedNext = new Date(target + (skipped + 1) * interval).toISOString()
expect(calculated).toEqual({
occurrenceAt: expectedOccurrence,
nextScheduledAt: expectedNext,
})
const folded = foldScheduleEvents([
event({ version: 1, operation: 'create', schedule: record }, 0),
event({
version: 1,
operation: 'dispatch',
id: record.id,
acceptedAt: new Date(accepted).toISOString(),
}, 1),
])
expect(folded.active).toEqual([{ ...record, scheduledAt: expectedNext }])
expect(folded.lastRecurringAcceptedAt).toBe(new Date(accepted).toISOString())
},
), { numRuns: 300 })
})
it('derives the 288-batch rolling-day bound from the fixed spacing', () => {
const spacing = 300_000
const day = 86_400_000
const accepted = Array.from({ length: 289 }, (_, index) => BASE + index * spacing)
expect(accepted.slice(0, 288).every(value => value >= BASE && value < BASE + day)).toBe(true)
expect(accepted[288]).toBe(BASE + day)
})
})

View File

@@ -7,6 +7,7 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import {
ScheduleId,
createAfterScheduleRecord,
createEveryScheduleRecord,
} from '../src/domain.ts'
import { MAX_TIMER_DELAY_MS, ScheduleOwner } from '../src/runtime.ts'
@@ -119,6 +120,17 @@ function appendAfter(
test.agent.session.append('schedule/change', { version: 1, operation: 'create', schedule: record })
}
function appendEvery(
test: RuntimeHarness,
id: string,
everySeconds = 300,
createdAt = Date.now(),
prompt = 'check metrics',
): void {
const record = createEveryScheduleRecord(ScheduleId(id), prompt, everySeconds, 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)
@@ -264,6 +276,78 @@ describe('Schedule timer and admission runtime', () => {
await owner.dispose()
})
it('batches every overdue fixed-rate record once in target and create order', 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')
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'))
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',
},
])
expect(test.controls.releaseCount).toBe(1)
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('rechecks the wall clock after claiming maintenance before queuing', async () => {
const test = await harness()
appendAfter(test, 'schedule-1', 1, Date.now() - 1_000)
@@ -305,6 +389,27 @@ 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', {
configurable: true,
value: [{
type: 'schedule/change',
seq: 0,
time: Date.now(),
data: { version: 9, operation: 'delete', id: 'schedule-corrupt' },
}],
})
}
const corruptOwner = ownerFor(corrupt)
corruptOwner.start()
await settle()
expect(corrupt.followed).toEqual([])
expect(corrupt.controls.releaseCount).toBe(1)
await corruptOwner.dispose()
})
})
@@ -331,6 +436,18 @@ 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 () => {

View File

@@ -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 } from '@deepseek-ai/dsh-llm'
import { CallId, createUserMessage } 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,8 +22,10 @@ interface ToolHarness {
readonly disposeTools: () => void
}
function stubAgent(ctx: Context, id: string): Agent {
const session = ctx.sessions.create(SessionId(id))
function stubAgent(ctx: Context, id: string, timeZone?: string): Agent {
const session = ctx.sessions.create(SessionId(id), {
...(timeZone === undefined ? {} : { meta: { timeZone } }),
})
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
return {
id: session.id,
@@ -33,23 +35,23 @@ function stubAgent(ctx: Context, id: 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): Promise<ToolHarness> {
async function harness(withPersistence = true, timeZone?: string): 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()}`)
const agent = stubAgent(ctx, `schedule-tools-${Math.random()}`, timeZone)
ctx.agents.register(agent)
const flushes = { count: 0, outcomes: [] as Array<'resolve' | 'reject' | Promise<'resolve' | 'reject'>> }
if (withPersistence) {
@@ -89,6 +91,25 @@ 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'))
@@ -152,8 +173,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 or at.',
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.' })
expect(test.flushes.count).toBe(0)
expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([])
})
@@ -204,7 +229,7 @@ describe('Schedule tool protocol', () => {
expect(test.flushes.count).toBe(0)
})
it('creates offset and explicit-zone at records without persisting their input interpretation', async () => {
it('creates explicit-offset and explicit-zone at records without persisting their interpretation', async () => {
const test = await harness()
expect(value(await execute(test, 'schedule_create', {
prompt: 'join meeting', at: '2026-08-06T09:00:00+08:00',
@@ -251,6 +276,229 @@ describe('Schedule tool protocol', () => {
])
})
it('creates and lists a fixed-rate record without persisting a separate anchor', async () => {
const test = await harness()
expect(value(await execute(test, 'schedule_create', {
prompt: ' check metrics ', every_seconds: 300,
}))).toEqual({
id: 'schedule-1',
kind: 'every',
prompt: 'check metrics',
everySeconds: 300,
scheduledAt: '2026-08-05T12:05:00.000Z',
state: 'scheduled',
deliveryMode: 'session-local',
})
vi.setSystemTime(new Date('2026-08-05T12:06:00.000Z'))
expect(value(await execute(test, 'schedule_list', {}))).toEqual([
expect.objectContaining({
id: 'schedule-1',
kind: 'every',
everySeconds: 300,
state: 'overdue',
}),
])
const create = test.agent.session.events.find(event => event.type === 'schedule/change')
expect(create?.data).not.toHaveProperty('anchorAt')
})
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 () => {
const test = await harness()
expect(value(await execute(test, 'schedule_create', {