refactor(schedule): make absolute times explicit
This commit is contained in:
@@ -207,19 +207,8 @@ describe('absolute record and time-zone resolution', () => {
|
||||
expect((error as ScheduleInputError).code).toBe('not_future')
|
||||
}
|
||||
}
|
||||
try {
|
||||
createAtScheduleRecord(
|
||||
ScheduleId('schedule-at'),
|
||||
'x',
|
||||
'9999-12-31T23:59:59.999-23:59',
|
||||
now,
|
||||
)
|
||||
throw new Error('expected range failure')
|
||||
} catch (error: unknown) {
|
||||
expect(error).toBeInstanceOf(ScheduleInputError)
|
||||
expect((error as ScheduleInputError).code).toBe('time_out_of_range')
|
||||
}
|
||||
for (const [at, sampleNow] of [
|
||||
['9999-12-31T23:59:59.999-23:59', now],
|
||||
['0001-01-01T00:00:00+23:59', Date.parse('0001-01-01T00:00:00.000Z') - 1],
|
||||
['2026-08-06T01:00:00Z', Number.NaN],
|
||||
] as const) {
|
||||
@@ -248,13 +237,10 @@ describe('absolute record and time-zone resolution', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves local calendar time, rejects a gap, and chooses the first overlap instant', () => {
|
||||
it('resolves explicit local time, rejects a DST gap, and chooses the first overlap instant', () => {
|
||||
expect(createAtScheduleRecord(ScheduleId('shanghai'), 'x', {
|
||||
date: '2026-08-06', time: '09:00:00', time_zone: 'Asia/Shanghai',
|
||||
}, now).scheduledAt).toBe('2026-08-06T01:00:00.000Z')
|
||||
expect(createAtScheduleRecord(ScheduleId('implicit'), 'x', {
|
||||
date: '2026-08-06', time: '09:00:00.25',
|
||||
}, now, 'Asia/Shanghai').scheduledAt).toBe('2026-08-06T01:00:00.250Z')
|
||||
date: '2026-08-06', time: '09:00:00.25', time_zone: 'Asia/Shanghai',
|
||||
}, now).scheduledAt).toBe('2026-08-06T01:00:00.250Z')
|
||||
expect(createAtScheduleRecord(ScheduleId('utc'), 'x', {
|
||||
date: '2026-08-06', time: '09:00:00', time_zone: 'UTC',
|
||||
}, now).scheduledAt).toBe('2026-08-06T09:00:00.000Z')
|
||||
@@ -273,6 +259,7 @@ describe('absolute record and time-zone resolution', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ date: '2026-08-06', time: '09:00:00' }],
|
||||
[{ date: '2026-08-06', time: '09:00:00', time_zone: 'UTC', extra: true }],
|
||||
[{ date: 20260806, time: '09:00:00', time_zone: 'UTC' }],
|
||||
[{ date: '2026-08-06', time: '09:00:00', time_zone: 8 }],
|
||||
@@ -289,7 +276,7 @@ describe('absolute record and time-zone resolution', () => {
|
||||
)).toThrow(ScheduleInputError)
|
||||
})
|
||||
|
||||
it('rejects empty at prompts and local instants outside the four-digit range', () => {
|
||||
it('rejects empty prompts and local instants outside the four-digit range', () => {
|
||||
expect(() => createAtScheduleRecord(
|
||||
ScheduleId('schedule-at'), ' ', '2026-08-06T01:00:00Z', now,
|
||||
)).toThrow(ScheduleInputError)
|
||||
@@ -304,19 +291,7 @@ describe('absolute record and time-zone resolution', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('fails closed when local calendar input has no confirmed zone', () => {
|
||||
try {
|
||||
createAtScheduleRecord(ScheduleId('schedule-at'), 'x', {
|
||||
date: '2026-08-06', time: '09:00:00',
|
||||
}, now)
|
||||
throw new Error('expected confirmation failure')
|
||||
} catch (error: unknown) {
|
||||
expect(error).toBeInstanceOf(ScheduleInputError)
|
||||
expect((error as ScheduleInputError).code).toBe('timezone_confirmation_required')
|
||||
}
|
||||
})
|
||||
|
||||
it('derives an at view and reminder framing without persisting input interpretation', () => {
|
||||
it('derives an at view and model framing without persisting input interpretation', () => {
|
||||
const record = createAtScheduleRecord(
|
||||
ScheduleId('schedule-at'),
|
||||
'join meeting',
|
||||
@@ -329,9 +304,5 @@ describe('absolute record and time-zone resolution', () => {
|
||||
deliveryMode: 'session-local',
|
||||
})
|
||||
expect(renderReminderFraming(record)).toContain('occurrence_at: 2026-08-06T01:00:00.000Z')
|
||||
expect(scheduleReminderPresentation([
|
||||
scheduleEvent(atCreateData(), 0),
|
||||
scheduleEvent({ version: 1, operation: 'dispatch', id: 'schedule-at' }, 1),
|
||||
], 1)).toMatchObject({ scheduleId: 'schedule-at', occurrenceAt: '2026-08-06T01:00:00.000Z' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'))
|
||||
@@ -225,7 +204,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',
|
||||
@@ -255,203 +234,6 @@ describe('Schedule tool protocol', () => {
|
||||
expect(changes[0]?.data).not.toHaveProperty('time_zone')
|
||||
})
|
||||
|
||||
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', {
|
||||
|
||||
Reference in New Issue
Block a user