feat(schedule): add absolute-time reminders
This commit is contained in:
@@ -36,7 +36,25 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
|
||||
z.object({ code: z.literal('cancelled'), message: z.string(), details: z.object({}) }),
|
||||
z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
|
||||
z.object({ code: z.literal('model-unavailable'), message: z.string(), details: z.object({ provider: z.string(), model: z.string() }) }),
|
||||
z.object({ code: z.literal('session-conflict'), message: z.string(), details: z.object({ sessionId: z.string(), requestedCwd: z.string(), existingCwd: z.string().optional() }) }),
|
||||
z.object({
|
||||
code: z.literal('session-conflict'),
|
||||
message: z.string(),
|
||||
details: z.object({
|
||||
sessionId: z.string(),
|
||||
requestedCwd: z.string(),
|
||||
existingCwd: z.string().optional(),
|
||||
requestedTimeZone: z.string(),
|
||||
existingTimeZone: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
code: z.literal('invalid-time-zone'),
|
||||
message: z.string(),
|
||||
details: z.object({
|
||||
field: z.union([z.literal('timeZone'), z.literal('clientTimeZone')]),
|
||||
value: z.union([z.string(), z.null()]),
|
||||
}),
|
||||
}),
|
||||
z.object({ code: z.literal('workspace-attach-failed'), message: z.string(), details: z.object({ sessionId: z.string(), workspaceId: z.string() }) }),
|
||||
z.object({ code: z.literal('workspace-not-found'), message: z.string(), details: z.object({ workspaceId: z.string() }) }),
|
||||
z.object({ code: z.literal('workspace-invalid-path'), message: z.string(), details: z.object({ path: z.string() }) }),
|
||||
|
||||
@@ -34,7 +34,14 @@ export interface RpcErrorDetailsMap {
|
||||
'cancelled': {}
|
||||
'session-not-found': { sessionId: SessionId }
|
||||
'model-unavailable': { provider: string; model: string }
|
||||
'session-conflict': { sessionId: SessionId; requestedCwd: string; existingCwd?: string }
|
||||
'session-conflict': {
|
||||
sessionId: SessionId
|
||||
requestedCwd: string
|
||||
existingCwd?: string
|
||||
requestedTimeZone: string
|
||||
existingTimeZone?: string
|
||||
}
|
||||
'invalid-time-zone': { field: 'timeZone' | 'clientTimeZone'; value: string | null }
|
||||
'workspace-attach-failed': { sessionId: SessionId; workspaceId: string }
|
||||
'workspace-not-found': { workspaceId: string }
|
||||
'workspace-invalid-path': { path: string }
|
||||
|
||||
@@ -100,6 +100,7 @@ export const sessionCreateRequestSchema = z.object({
|
||||
workspaceId: workspaceIdSchema.optional(),
|
||||
cwd: z.string().optional(),
|
||||
sessionId: sessionIdSchema.optional(),
|
||||
timeZone: z.string().optional(),
|
||||
}).refine(
|
||||
payload => payload.workspaceId === undefined || payload.cwd === undefined,
|
||||
{ message: 'session.create accepts workspaceId or cwd, not both' },
|
||||
@@ -251,6 +252,7 @@ export const sessionPromptRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
mode: z.union([z.literal('queue'), z.literal('steer')]),
|
||||
content: z.array(contentBlockSchema),
|
||||
clientTimeZone: z.string().optional(),
|
||||
}) as unknown as z.ZodType<RequestPayload<'session.prompt'>>
|
||||
|
||||
/** session.prompt response value (the command slot appears only when the prompt dispatched a slash command). */
|
||||
|
||||
@@ -22,7 +22,7 @@ declare module '@deepseek-ai/dsh-llm' {
|
||||
* echoed provisional message with the event stream). kind stays `'user'` — the model face
|
||||
* carries no transport vocabulary; rpcId is an extra durable-JSON field passed back to the client with the event.
|
||||
*/
|
||||
'user-rpc': { kind: 'user'; rpcId: RpcId }
|
||||
'user-rpc': { kind: 'user'; rpcId: RpcId; clientTimeZone: string }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,12 +204,19 @@ export interface SessionsApi {
|
||||
/**
|
||||
* Creates a real session and its idle agent. At most one of `workspaceId` /
|
||||
* `cwd` is accepted; an omitted project uses the Host cwd. A caller may
|
||||
* preallocate `sessionId`: retries with the same id and cwd return the same
|
||||
* session, while a different cwd fails with `session-conflict`. Workspace
|
||||
* preallocate `sessionId`: retries with the same id, cwd, and canonical time
|
||||
* zone return the same session, while a different owned identity fails with
|
||||
* `session-conflict`. A headerless persisted session remains compatible with
|
||||
* the same cwd but never absorbs the request zone. Workspace
|
||||
* creation attaches the session after publication; an attach failure
|
||||
* returns `workspace-attach-failed` with the published session id.
|
||||
*/
|
||||
create(request: RpcRequest<{ workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId }>):
|
||||
create(request: RpcRequest<{
|
||||
workspaceId?: WorkspaceId
|
||||
cwd?: string
|
||||
sessionId?: SessionId
|
||||
timeZone?: string
|
||||
}>):
|
||||
Promise<RpcResponse<{ sessionId: SessionId }>>
|
||||
|
||||
/**
|
||||
@@ -289,7 +296,12 @@ export interface SessionsApi {
|
||||
Promise<RpcResponse<{ sessionId: SessionId }>>
|
||||
|
||||
/** Sends a message to an ordinary session Agent. Session-backed subagents reject with `agent-busy` and use `subagent.prompt`. */
|
||||
prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>):
|
||||
prompt(request: RpcRequest<{
|
||||
sessionId: SessionId
|
||||
mode: 'queue' | 'steer'
|
||||
content: ContentBlock[]
|
||||
clientTimeZone?: string
|
||||
}>):
|
||||
Promise<RpcResponse<{ accepted: true; command?: { kind: 'success'; text?: string } }>>
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user