refactor(schedule): make absolute times explicit
This commit is contained in:
@@ -31,10 +31,7 @@ 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: { timeZone: 'UTC', clientTimeZone: 'UTC', ...payload },
|
||||
}
|
||||
return { rpcId: RpcId(`cold-${String(nextRpc++)}`), payload }
|
||||
}
|
||||
|
||||
function header(id: string, createdAt: number, extra: Partial<SessionHeader> = {}): SessionHeader {
|
||||
@@ -471,6 +468,81 @@ describe('subagent ownership fence', () => {
|
||||
expect(response.result.ok).toBe(true)
|
||||
expect(followup).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('canonicalizes a supplied browser zone on the exact prompt and rejects invalid names', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const session = ctx.sessions.create(sid('session-browser-zone'), { meta: { cwd: '/proj' } })
|
||||
const followup = vi.fn()
|
||||
const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent
|
||||
ctx.agents.register(agent)
|
||||
const api = createApiProxy(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
|
||||
cwd: '/tmp',
|
||||
workspaceRoot: '/tmp',
|
||||
})
|
||||
|
||||
const alias = 'US/Pacific'
|
||||
const canonical = new Intl.DateTimeFormat('en-US', { timeZone: alias })
|
||||
.resolvedOptions().timeZone
|
||||
const zonedRequest = request({
|
||||
sessionId: agent.id,
|
||||
mode: 'queue' as const,
|
||||
content: [{ type: 'text' as const, text: 'zoned work' }],
|
||||
clientTimeZone: alias,
|
||||
})
|
||||
await expect(api.sessions.prompt(zonedRequest)).resolves.toMatchObject({
|
||||
result: { ok: true },
|
||||
})
|
||||
expect(followup).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||
source: { kind: 'user', rpcId: zonedRequest.rpcId, clientTimeZone: canonical },
|
||||
}))
|
||||
|
||||
const utcRequest = request({
|
||||
sessionId: agent.id,
|
||||
mode: 'queue' as const,
|
||||
content: [{ type: 'text' as const, text: 'UTC work' }],
|
||||
clientTimeZone: 'UTC',
|
||||
})
|
||||
await expect(api.sessions.prompt(utcRequest)).resolves.toMatchObject({
|
||||
result: { ok: true },
|
||||
})
|
||||
expect(followup).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||
source: { kind: 'user', rpcId: utcRequest.rpcId, clientTimeZone: 'UTC' },
|
||||
}))
|
||||
|
||||
const unzonedRequest = request({
|
||||
sessionId: agent.id,
|
||||
mode: 'queue' as const,
|
||||
content: [{ type: 'text' as const, text: 'headless work' }],
|
||||
})
|
||||
await expect(api.sessions.prompt(unzonedRequest)).resolves.toMatchObject({
|
||||
result: { ok: true },
|
||||
})
|
||||
expect(followup).toHaveBeenNthCalledWith(3, expect.objectContaining({
|
||||
source: { kind: 'user', rpcId: unzonedRequest.rpcId },
|
||||
}))
|
||||
|
||||
for (const clientTimeZone of ['', ' UTC', 'CST', 'Not/A_Real_Zone']) {
|
||||
const invalid = await api.sessions.prompt(request({
|
||||
sessionId: agent.id,
|
||||
mode: 'queue' as const,
|
||||
content: [{ type: 'text' as const, text: 'invalid zone' }],
|
||||
clientTimeZone,
|
||||
}))
|
||||
expect(invalid.result).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'invalid-time-zone',
|
||||
message: 'clientTimeZone must be UTC or a valid IANA Area/Location name',
|
||||
details: { value: clientTimeZone },
|
||||
},
|
||||
})
|
||||
}
|
||||
expect(followup).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('degenerate composition (no persistence, no factory)', () => {
|
||||
@@ -513,89 +585,6 @@ 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, { defaultTarget: () => ({ 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()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a missing zone', undefined, null],
|
||||
['an invalid zone', 'CST', 'CST'],
|
||||
] as const)('rejects %s before resuming a cold Session', async (_case, clientTimeZone, detailValue) => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const sessionId = sid('session-cold-prompt-zone')
|
||||
const meta = header('session-cold-prompt-zone', 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, {
|
||||
defaultTarget: () => ({ provider: 'p', model: 'm' }),
|
||||
cwd: '/tmp',
|
||||
workspaceRoot: '/tmp',
|
||||
})
|
||||
|
||||
const promptRequest = request({
|
||||
sessionId,
|
||||
mode: 'queue' as const,
|
||||
content: [{ type: 'text' as const, text: 'rejected before resume' }],
|
||||
clientTimeZone: clientTimeZone ?? 'UTC',
|
||||
})
|
||||
if (clientTimeZone === undefined) {
|
||||
delete (promptRequest.payload as { clientTimeZone?: string }).clientTimeZone
|
||||
}
|
||||
const response = await api.sessions.prompt(promptRequest)
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'invalid-time-zone',
|
||||
details: { field: 'clientTimeZone', value: detailValue },
|
||||
},
|
||||
})
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
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'; timeZone?: string } = {},
|
||||
lineage: { parentSession?: SessionId; origin?: 'subagent' } = {},
|
||||
): 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, 'none', { timeZone: 'Asia/Shanghai' })
|
||||
const source = liveAgent(ctx, 'session-source', 2)
|
||||
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,7 +100,6 @@ 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()
|
||||
})
|
||||
|
||||
@@ -158,7 +157,6 @@ describe('sessions.fork', () => {
|
||||
id: sourceId,
|
||||
createdAt: 1,
|
||||
cwd: '/proj',
|
||||
timeZone: 'America/New_York',
|
||||
parentSession: parentId,
|
||||
origin: 'subagent',
|
||||
}
|
||||
@@ -197,7 +195,6 @@ 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()
|
||||
|
||||
@@ -315,7 +315,6 @@ describe('Web session model selection', () => {
|
||||
// callable, so the refusal has to live here.
|
||||
const refused = await api.sessions.prompt(request({
|
||||
sessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'hi' }],
|
||||
clientTimeZone: 'UTC',
|
||||
}))
|
||||
expect(refused.result).toMatchObject({
|
||||
ok: false,
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
/**
|
||||
* Schedule reminder views cross the Host only after persistence proves their
|
||||
* dispatch prefix. Live append sends raw events; session/flushed replays the
|
||||
* identical dispatch with a generic sidecar. History independently gates the
|
||||
* same projection on an identity-matching stored prefix.
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
import { ScheduleId } from '@deepseek-ai/dsh-tool-schedule'
|
||||
|
||||
interface FlushControl {
|
||||
handler: () => true | Promise<true>
|
||||
}
|
||||
|
||||
function reminderCreateData(id: string, prompt: string) {
|
||||
return {
|
||||
version: 1 as const,
|
||||
operation: 'create' as const,
|
||||
schedule: {
|
||||
id: ScheduleId(id),
|
||||
kind: 'after' as const,
|
||||
prompt,
|
||||
afterSeconds: 1,
|
||||
scheduledAt: '2026-08-05T12:00:01.000Z',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function harness(control?: FlushControl): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
if (control !== undefined) ctx.on('session/flush', () => control.handler())
|
||||
return ctx
|
||||
}
|
||||
|
||||
function appendReminder(
|
||||
session: Session,
|
||||
id: string,
|
||||
prompt: string,
|
||||
): { create: SessionEvent; dispatch: SessionEvent } {
|
||||
const scheduleId = ScheduleId(id)
|
||||
const create = session.append('schedule/change', reminderCreateData(id, prompt))
|
||||
const dispatch = session.append('schedule/change', {
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: scheduleId,
|
||||
})
|
||||
return { create, dispatch }
|
||||
}
|
||||
|
||||
async function collectEvents(
|
||||
iterable: AsyncIterable<RpcRequest<MuxFrame>>,
|
||||
count: number,
|
||||
abort: AbortController,
|
||||
): Promise<Extract<MuxFrame, { type: 'session/event' }>[]> {
|
||||
const events: Extract<MuxFrame, { type: 'session/event' }>[] = []
|
||||
for await (const envelope of iterable) {
|
||||
if (envelope.payload.type !== 'session/event') continue
|
||||
events.push(envelope.payload)
|
||||
if (events.length >= count) abort.abort()
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
describe('commit-aware Schedule live views', () => {
|
||||
it('takes the max of reverse flush completion and replays each dispatch once', async () => {
|
||||
const first = Promise.withResolvers<true>()
|
||||
let calls = 0
|
||||
const ctx = await harness({
|
||||
handler: () => ++calls === 1 ? first.promise : true,
|
||||
})
|
||||
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const abort = new AbortController()
|
||||
const collected = collectEvents(
|
||||
api.events.mux({ rpcId: RpcId('schedule-live'), payload: {} }, abort.signal),
|
||||
6,
|
||||
abort,
|
||||
)
|
||||
const session = ctx.sessions.create(SessionId('schedule-live'))
|
||||
const firstPair = appendReminder(session, 'schedule-1', 'first')
|
||||
const slow = ctx.sessions.flush(session)
|
||||
const secondPair = appendReminder(session, 'schedule-2', 'second')
|
||||
await expect(ctx.sessions.flush(session)).resolves.toBe(true)
|
||||
first.resolve(true)
|
||||
await expect(slow).resolves.toBe(true)
|
||||
|
||||
const frames = await collected
|
||||
const raw = frames.filter(frame => frame.view === undefined)
|
||||
const presented = frames.filter(frame => frame.view?.for === 'event')
|
||||
expect(raw.map(frame => frame.event.seq)).toEqual([0, 1, 2, 3])
|
||||
expect(presented.map(frame => frame.event.seq)).toEqual([1, 3])
|
||||
expect(presented[0]?.event).toBe(firstPair.dispatch)
|
||||
expect(presented[1]?.event).toBe(secondPair.dispatch)
|
||||
expect(presented.map(frame => frame.view)).toEqual([
|
||||
{
|
||||
for: 'event',
|
||||
view: {
|
||||
scheduleId: 'schedule-1', prompt: 'first',
|
||||
occurrenceAt: '2026-08-05T12:00:01.000Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
for: 'event',
|
||||
view: {
|
||||
scheduleId: 'schedule-2', prompt: 'second',
|
||||
occurrenceAt: '2026-08-05T12:00:01.000Z',
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(firstPair.create.seq).toBe(0)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('withholds a view after rejection and publishes it on the next successful checkpoint', async () => {
|
||||
let calls = 0
|
||||
const ctx = await harness({
|
||||
handler: () => ++calls === 1 ? Promise.reject(new Error('disk unavailable')) : true,
|
||||
})
|
||||
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const abort = new AbortController()
|
||||
const collected = collectEvents(
|
||||
api.events.mux({ rpcId: RpcId('schedule-retry'), payload: {} }, abort.signal),
|
||||
3,
|
||||
abort,
|
||||
)
|
||||
const session = ctx.sessions.create(SessionId('schedule-retry'))
|
||||
appendReminder(session, 'schedule-1', 'retry me')
|
||||
await expect(ctx.sessions.flush(session)).rejects.toThrow('disk unavailable')
|
||||
await expect(ctx.sessions.flush(session)).resolves.toBe(true)
|
||||
|
||||
const frames = await collected
|
||||
expect(frames.filter(frame => frame.view?.for === 'event')).toHaveLength(1)
|
||||
expect(frames.at(-1)?.view).toMatchObject({
|
||||
for: 'event',
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Schedule history views', () => {
|
||||
it('presents a resumed ancestor dispatch copied into a fork seed', async () => {
|
||||
const ctx = await harness()
|
||||
const scheduleId = ScheduleId('resumed-reminder')
|
||||
const resumed = ctx.sessions.create(SessionId('schedule-resumed'), {
|
||||
seed: [{
|
||||
type: 'schedule/change',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: reminderCreateData('resumed-reminder', 'after restart'),
|
||||
}],
|
||||
meta: { cwd: '/tmp' },
|
||||
})
|
||||
const dispatch = resumed.append('schedule/change', {
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: scheduleId,
|
||||
})
|
||||
const child = ctx.sessions.fork(resumed, undefined, SessionId('schedule-fork'))
|
||||
ctx.provide('sessionPersistence', {
|
||||
readFrom: () => Promise.resolve({ meta: child.header, events: [...child.events] }),
|
||||
} as never)
|
||||
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
|
||||
const response = await api.sessions.history({
|
||||
rpcId: RpcId('schedule-resumed-fork'), payload: { sessionId: child.id },
|
||||
})
|
||||
if (!response.result.ok) throw new Error(response.result.error.message)
|
||||
expect(response.result.value.events.find(entry => entry.event.seq === dispatch.seq)?.view).toEqual({
|
||||
for: 'event',
|
||||
view: {
|
||||
scheduleId,
|
||||
prompt: 'after restart',
|
||||
occurrenceAt: '2026-08-05T12:00:01.000Z',
|
||||
},
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('uses only the attached identity-matching stored prefix and fails soft to raw history', async () => {
|
||||
const ctx = await harness()
|
||||
const parent = ctx.sessions.create(SessionId('schedule-parent'), { meta: { cwd: '/tmp' } })
|
||||
appendReminder(parent, 'parent-reminder', 'from parent')
|
||||
const session = ctx.sessions.create(SessionId('schedule-attached'), {
|
||||
seed: [...parent.events],
|
||||
meta: { cwd: '/tmp', parentSession: parent.id, seedLength: 2 },
|
||||
})
|
||||
let readFrom = (): Promise<{ meta: SessionHeader; events: SessionEvent[] }> => Promise.resolve({
|
||||
meta: session.header,
|
||||
events: [...session.events.slice(0, 1)],
|
||||
})
|
||||
ctx.provide('sessionPersistence', {
|
||||
readFrom: () => readFrom(),
|
||||
} as never)
|
||||
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const history = async () => {
|
||||
const response = await api.sessions.history({
|
||||
rpcId: RpcId('schedule-history'), payload: { sessionId: session.id },
|
||||
})
|
||||
if (!response.result.ok) throw new Error(response.result.error.message)
|
||||
return response.result.value.events
|
||||
}
|
||||
|
||||
expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
|
||||
readFrom = () => Promise.resolve({
|
||||
meta: { ...session.header, delegationDepth: 0 },
|
||||
events: [...session.events.slice(0, 2)],
|
||||
})
|
||||
expect((await history()).find(entry => entry.event.seq === 1)?.view).toMatchObject({
|
||||
for: 'event',
|
||||
})
|
||||
readFrom = () => Promise.resolve({
|
||||
meta: { ...session.header, cwd: '/different', delegationDepth: 0 },
|
||||
events: [...session.events.slice(0, 2)],
|
||||
})
|
||||
expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
|
||||
readFrom = () => Promise.resolve({
|
||||
meta: { ...session.header, timeZone: 'UTC', delegationDepth: 0 },
|
||||
events: [...session.events.slice(0, 2)],
|
||||
})
|
||||
expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
|
||||
readFrom = () => Promise.reject(new Error('physical read unavailable'))
|
||||
expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('presents every dispatch in detached persisted history', async () => {
|
||||
const ctx = await harness()
|
||||
let source: Session | undefined
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
source = inner.sessions.create(SessionId('schedule-source'), { meta: { cwd: '/tmp' } })
|
||||
}, { inject: ['sessions'] }))
|
||||
if (source === undefined) throw new Error('session owner did not publish its session')
|
||||
appendReminder(source, 'schedule-1', 'cold reminder')
|
||||
const meta = source.header
|
||||
const events = [...source.events]
|
||||
await owner.dispose()
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve([meta]),
|
||||
inspect: () => Promise.resolve({ meta, events }),
|
||||
readFrom: () => Promise.resolve({ meta, events }),
|
||||
} as never)
|
||||
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const response = await api.sessions.history({
|
||||
rpcId: RpcId('schedule-cold'), payload: { sessionId: meta.id },
|
||||
})
|
||||
if (!response.result.ok) throw new Error(response.result.error.message)
|
||||
expect(response.result.value.events.find(entry => entry.event.seq === 1)?.view).toMatchObject({
|
||||
for: 'event',
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('withholds a detached view that exists only in a logical inspection', async () => {
|
||||
const ctx = await harness()
|
||||
let source: Session | undefined
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
source = inner.sessions.create(SessionId('schedule-logical-only'), { meta: { cwd: '/tmp' } })
|
||||
}, { inject: ['sessions'] }))
|
||||
if (source === undefined) throw new Error('session owner did not publish its session')
|
||||
appendReminder(source, 'schedule-logical', 'not physically committed')
|
||||
const meta = source.header
|
||||
const events = [...source.events]
|
||||
await owner.dispose()
|
||||
let physicalEvents = events.slice(0, 1)
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve([meta]),
|
||||
inspect: () => Promise.resolve({ meta, events }),
|
||||
readFrom: () => Promise.resolve({ meta, events: physicalEvents }),
|
||||
} as never)
|
||||
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const history = async () => {
|
||||
const response = await api.sessions.history({
|
||||
rpcId: RpcId('schedule-logical-only-history'), payload: { sessionId: meta.id },
|
||||
})
|
||||
if (!response.result.ok) throw new Error(response.result.error.message)
|
||||
return response.result.value.events
|
||||
}
|
||||
|
||||
expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
|
||||
physicalEvents = events
|
||||
expect((await history()).find(entry => entry.event.seq === 1)?.view).toMatchObject({ for: 'event' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -22,10 +22,7 @@ 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: { timeZone: 'UTC', clientTimeZone: 'UTC', ...payload },
|
||||
}
|
||||
return { rpcId: RpcId(`workspace-${String(nextRpc++)}`), payload }
|
||||
}
|
||||
|
||||
function expectOk<T>(response: RpcResponse<T>): T {
|
||||
@@ -362,157 +359,6 @@ 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 invalidRequest = request({})
|
||||
Object.assign(invalidRequest.payload, { timeZone })
|
||||
const response = await api.sessions.create(invalidRequest)
|
||||
|
||||
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 invalidRequest = request({
|
||||
sessionId,
|
||||
mode: 'queue' as const,
|
||||
content: [{ type: 'text' as const, text: 'rejected' }],
|
||||
})
|
||||
Object.assign(invalidRequest.payload, { clientTimeZone })
|
||||
const response = await api.sessions.prompt(invalidRequest)
|
||||
|
||||
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({ timeZone: 'UTC' })).result.ok).toBe(true)
|
||||
expect((await c.sessions.create({})).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,12 +330,7 @@ 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' }],
|
||||
clientTimeZone: 'UTC',
|
||||
})).result.ok).toBe(true)
|
||||
expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true)
|
||||
expect((await c.sessions.updateQueue({
|
||||
sessionId: 's' as never,
|
||||
itemId: 'item-1' as never,
|
||||
|
||||
@@ -59,7 +59,8 @@ describe('rpcErrorSchema', () => {
|
||||
expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request')
|
||||
expect(rpcErrorSchema.parse({ code: 'cancelled', message: 'm', details: {} }).code).toBe('cancelled')
|
||||
expect(rpcErrorSchema.parse({ code: 'session-not-found', message: 'm', details: { sessionId: 's' } }).code).toBe('session-not-found')
|
||||
expect(rpcErrorSchema.parse({ code: 'session-conflict', message: 'm', details: { sessionId: 's', requestedCwd: '/a', existingCwd: '/b', requestedTimeZone: 'UTC' } }).code).toBe('session-conflict')
|
||||
expect(rpcErrorSchema.parse({ code: 'session-conflict', message: 'm', details: { sessionId: 's', requestedCwd: '/a', existingCwd: '/b' } }).code).toBe('session-conflict')
|
||||
expect(rpcErrorSchema.parse({ code: 'invalid-time-zone', message: 'm', details: { value: 'CST' } }).code).toBe('invalid-time-zone')
|
||||
expect(rpcErrorSchema.parse({ code: 'workspace-attach-failed', message: 'm', details: { sessionId: 's', workspaceId: 'w' } }).code).toBe('workspace-attach-failed')
|
||||
expect(rpcErrorSchema.parse({ code: 'workspace-not-found', message: 'm', details: { workspaceId: 'w' } }).code).toBe('workspace-not-found')
|
||||
expect(rpcErrorSchema.parse({ code: 'workspace-invalid-path', message: 'm', details: { path: '/x' } }).code).toBe('workspace-invalid-path')
|
||||
@@ -245,8 +246,17 @@ describe('sessions domain schemas', () => {
|
||||
}],
|
||||
failures: [],
|
||||
})).toThrow()
|
||||
const prompt = sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'queue', content: [{ type: 'text', text: 'hi' }] })
|
||||
const prompt = sessionPromptRequestSchema.parse({
|
||||
sessionId: 's1',
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text: 'hi' }],
|
||||
clientTimeZone: 'Asia/Shanghai',
|
||||
})
|
||||
expect(prompt.mode).toBe('queue')
|
||||
expect(prompt.clientTimeZone).toBe('Asia/Shanghai')
|
||||
expect(sessionPromptRequestSchema.parse({
|
||||
sessionId: 's1', mode: 'queue', content: [],
|
||||
}).clientTimeZone).toBeUndefined()
|
||||
expect(() => sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'inject', content: [] })).toThrow()
|
||||
expect(sessionPromptValueSchema.parse({ accepted: true }).accepted).toBe(true)
|
||||
// The command slot appears only when the prompt dispatched a slash command.
|
||||
|
||||
Reference in New Issue
Block a user