feat(schedule): add absolute-time reminders

This commit is contained in:
pku-xht
2026-08-06 15:03:48 +08:00
committed by Tianyi Cui
parent 9e61b7d1b1
commit d61059364e
54 changed files with 3169 additions and 374 deletions

View File

@@ -31,7 +31,10 @@ const sid = (id: string): SessionId => id as SessionId
let nextRpc = 1
function request<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId(`cold-${String(nextRpc++)}`), payload }
return {
rpcId: RpcId(`cold-${String(nextRpc++)}`),
payload: { timeZone: 'UTC', clientTimeZone: 'UTC', ...payload },
}
}
function header(id: string, createdAt: number, extra: Partial<SessionHeader> = {}): SessionHeader {
@@ -510,6 +513,44 @@ describe('degenerate composition (no persistence, no factory)', () => {
})
})
describe('cold Session zone identity', () => {
it('rejects a different requested zone before resuming a persisted identity', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const sessionId = sid('session-cold-zone-conflict')
const meta = header('session-cold-zone-conflict', 1000, { timeZone: 'UTC' })
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events: [] as SessionEvent[] }),
locate: () => undefined,
} as never)
const resume = vi.spyOn(ctx.agents, 'resume')
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
const response = await api.sessions.create(request({
sessionId,
cwd: '/proj',
timeZone: 'Asia/Shanghai',
}))
expect(response.result).toMatchObject({
ok: false,
error: {
code: 'session-conflict',
details: {
sessionId,
existingCwd: '/proj',
existingTimeZone: 'UTC',
requestedTimeZone: 'Asia/Shanghai',
},
},
})
expect(resume).not.toHaveBeenCalled()
})
})
describe('sessions.prompt synchronous rejection', () => {
it('maps a synchronous send throw (disposed/invalid input) to agent-busy with the reason attached', async () => {
const ctx = new Context()

View File

@@ -55,7 +55,7 @@ function liveAgent(
id: string,
turns: number,
tail: Tail = 'none',
lineage: { parentSession?: SessionId; origin?: 'subagent' } = {},
lineage: { parentSession?: SessionId; origin?: 'subagent'; timeZone?: string } = {},
): Session {
const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj', ...lineage } })
for (let turn = 1; turn <= turns; turn++) {
@@ -90,7 +90,7 @@ const api = (ctx: Context) => createApiProxy(ctx, {
describe('sessions.fork', () => {
it('cuts at the anchored completed turn and records lineage and cwd', async () => {
const ctx = await composed()
const source = liveAgent(ctx, 'session-source', 2)
const source = liveAgent(ctx, 'session-source', 2, 'none', { timeZone: 'Asia/Shanghai' })
const response = await api(ctx).sessions.fork(request({ sessionId: source.id, atSeq: 1 }))
expect(response.result.ok).toBe(true)
if (!response.result.ok) return
@@ -100,6 +100,7 @@ describe('sessions.fork', () => {
])
expect(child?.header.parentSession).toBe(source.id)
expect(child?.header.cwd).toBe('/proj')
expect(child?.header.timeZone).toBe('Asia/Shanghai')
await ctx.fiber.dispose()
})
@@ -157,6 +158,7 @@ describe('sessions.fork', () => {
id: sourceId,
createdAt: 1,
cwd: '/proj',
timeZone: 'America/New_York',
parentSession: parentId,
origin: 'subagent',
}
@@ -195,6 +197,7 @@ describe('sessions.fork', () => {
expect(ctx.sessions.get(response.result.value.sessionId)?.header).toMatchObject({
parentSession: sourceId,
cwd: '/proj',
timeZone: 'America/New_York',
})
expect(ctx.sessions.get(response.result.value.sessionId)?.header.origin).toBeUndefined()
await ctx.fiber.dispose()

View File

@@ -22,7 +22,10 @@ import { MemoryStorageBackend } from '../../../storage/storage-domain/tests/help
let nextRpc = 1
function request<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId(`workspace-${String(nextRpc++)}`), payload }
return {
rpcId: RpcId(`workspace-${String(nextRpc++)}`),
payload: { timeZone: 'UTC', clientTimeZone: 'UTC', ...payload },
}
}
function expectOk<T>(response: RpcResponse<T>): T {
@@ -359,6 +362,154 @@ describe('session creation and Workspace membership', () => {
expectOk(await api.sessions.create(request({ workspaceId: created.workspaceId, sessionId })))
expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId])
})
it('canonicalizes the immutable Session zone and rejects identity conflicts', async () => {
const { api, ctx, workspaceRoot } = await harness()
const sessionId = SessionId('session-zone-identity')
const alias = 'US/Eastern'
const canonical = new Intl.DateTimeFormat('en-US', { timeZone: alias })
.resolvedOptions().timeZone
expectOk(await api.sessions.create(request({ sessionId, cwd: workspaceRoot, timeZone: alias })))
expect(ctx.agents.get(sessionId)?.session.header.timeZone).toBe(canonical)
expectOk(await api.sessions.create(request({ sessionId, cwd: workspaceRoot, timeZone: canonical })))
const conflict = await api.sessions.create(request({
sessionId,
cwd: workspaceRoot,
timeZone: 'Asia/Shanghai',
}))
expect(conflict.result).toMatchObject({
ok: false,
error: {
code: 'session-conflict',
details: {
sessionId,
requestedCwd: workspaceRoot,
requestedTimeZone: 'Asia/Shanghai',
existingTimeZone: canonical,
},
},
})
})
it('keeps a live headerless Session compatible without absorbing a request zone', async () => {
const { api, ctx, workspaceRoot } = await harness()
const session = ctx.sessions.create(SessionId('session-zone-headerless'), {
meta: { cwd: workspaceRoot },
})
ctx.agents.register(stubAgent(session))
expectOk(await api.sessions.create(request({
sessionId: session.id,
cwd: workspaceRoot,
timeZone: 'Asia/Shanghai',
})))
expect(session.header.timeZone).toBeUndefined()
})
it('serializes different-zone creates so the first immutable identity wins', async () => {
const { api, ctx, workspaceRoot } = await harness()
const sessionId = SessionId('session-zone-race')
const first = api.sessions.create(request({
sessionId,
cwd: workspaceRoot,
timeZone: 'UTC',
}))
const second = api.sessions.create(request({
sessionId,
cwd: workspaceRoot,
timeZone: 'Asia/Shanghai',
}))
const [firstResult, secondResult] = await Promise.all([first, second])
expect(firstResult.result).toMatchObject({ ok: true, value: { sessionId } })
expect(secondResult.result).toMatchObject({
ok: false,
error: { code: 'session-conflict', details: { existingTimeZone: 'UTC' } },
})
expect(ctx.agents.get(sessionId)?.session.header.timeZone).toBe('UTC')
})
it.each([
[undefined, null],
['', ''],
[' UTC', ' UTC'],
['CST', 'CST'],
['GMT', 'GMT'],
['+08:00', '+08:00'],
['Not/A_Real_Zone', 'Not/A_Real_Zone'],
] as const)('rejects invalid Session zone input %j before Agent creation', async (timeZone, value) => {
const { api, ctx } = await harness()
const response = await api.sessions.create(request({ timeZone }))
expect(response.result).toMatchObject({
ok: false,
error: { code: 'invalid-time-zone', details: { field: 'timeZone', value } },
})
expect(ctx.agents.list()).toHaveLength(0)
})
it('binds each canonical client zone to its own queued or steering message source', async () => {
const { api, ctx } = await harness()
const sessionId = expectOk(await api.sessions.create(request({ timeZone: 'UTC' }))).sessionId
const agent = ctx.agents.get(sessionId)
if (agent === undefined) throw new Error('created Agent missing')
const followup = vi.spyOn(agent, 'followup')
const steer = vi.spyOn(agent, 'steer')
const alias = 'US/Eastern'
const canonical = new Intl.DateTimeFormat('en-US', { timeZone: alias })
.resolvedOptions().timeZone
expectOk(await api.sessions.prompt(request({
sessionId,
mode: 'queue',
content: [{ type: 'text', text: 'queue' }],
clientTimeZone: alias,
})))
expectOk(await api.sessions.prompt(request({
sessionId,
mode: 'steer',
content: [{ type: 'text', text: 'steer' }],
clientTimeZone: 'Asia/Shanghai',
})))
expect(followup.mock.calls[0]?.[0].source).toMatchObject({
kind: 'user',
clientTimeZone: canonical,
})
expect(steer.mock.calls[0]?.[0].source).toMatchObject({
kind: 'user',
clientTimeZone: 'Asia/Shanghai',
})
})
it.each([undefined, '', 'CST', 'Not/A_Real_Zone'] as const)(
'rejects invalid prompt zone input %j before delivery',
async (clientTimeZone) => {
const { api, ctx } = await harness()
const sessionId = expectOk(await api.sessions.create(request({ timeZone: 'UTC' }))).sessionId
const agent = ctx.agents.get(sessionId)
if (agent === undefined) throw new Error('created Agent missing')
const followup = vi.spyOn(agent, 'followup')
const response = await api.sessions.prompt(request({
sessionId,
mode: 'queue',
content: [{ type: 'text', text: 'rejected' }],
clientTimeZone,
}))
expect(response.result).toMatchObject({
ok: false,
error: {
code: 'invalid-time-zone',
details: { field: 'clientTimeZone', value: clientTimeZone ?? null },
},
})
expect(followup).not.toHaveBeenCalled()
},
)
})
describe('Host Workspace increments', () => {

View File

@@ -310,7 +310,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
ok: true,
value: { items: [{ sessionId: 's1', snippet: 'fixture match' }], hasMore: false },
})
expect((await c.sessions.create({})).result.ok).toBe(true)
expect((await c.sessions.create({ timeZone: 'UTC' })).result.ok).toBe(true)
expect((await c.sessions.models({ sessionId: 's' as never })).result.ok).toBe(true)
const selected = await c.sessions.selectModel({
sessionId: 's' as never,
@@ -330,7 +330,12 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
})
const renamed = await c.sessions.rename({ sessionId: 's' as never, title: 'named' })
expect(renamed.result).toMatchObject({ ok: true, value: { title: 'named', seq: 0 } })
expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true)
expect((await c.sessions.prompt({
sessionId: 's' as never,
mode: 'queue',
content: [{ type: 'text', text: 'x' }],
clientTimeZone: 'UTC',
})).result.ok).toBe(true)
expect((await c.sessions.updateQueue({
sessionId: 's' as never,
itemId: 'item-1' as never,