fix(time-context): close request-boundary gaps

This commit is contained in:
pku-xht
2026-08-07 21:55:18 +08:00
committed by Tianyi Cui
parent d8a85dcafd
commit 331b29d779
9 changed files with 78 additions and 5 deletions

View File

@@ -22,7 +22,7 @@ When a Session has `SessionHeader.timeZone`, that immutable IANA zone formats it
The plugin prepends an `agent/pre-step` listener and delegates first. When the downstream decision enters a non-empty message batch, time-context derives client zones from those final messages plus user-rpc messages already entered in the open turn, then appends one reading to that decision. Schedule later derives the same facts directly from the immutable Session header and those durable user-rpc sources; the reading is not a second machine authority.
An entering non-empty batch records its downstream messages followed by exactly one time-context `UserMessage` after `step/start`. Its source is the simple marker `{ kind: 'plugin', plugin: 'time-context' }`; the Session header and original user-rpc sources remain the only machine-readable zone owners. A decision rewritten to empty never gains a reading: it opens no initial step, and an empty tool continuation may still enter a later step using existing history.
An entering non-empty batch records its downstream messages followed by exactly one time-context `UserMessage` after `step/start`. Its source is the exact snapshot marker `{ kind: 'plugin', plugin: 'time-context', form: 'snapshot', sections: [{ name: 'time-context', text: <same rendered text> }] }`; the invariant companion and Schedule consumer both fail closed if that shape or text equality drifts. The Session header and original user-rpc sources remain the only machine-readable zone owners. A decision rewritten to empty never gains a reading: it opens no initial step, and an empty tool continuation may still enter a later step using existing history.
Reject, cancellation, and listener failure before `step/start` add no reading. A plugin disposal that wins while the listener awaits downstream work also prevents the in-flight listener from contributing. Steering inserted after AgentLoop has claimed the current batch retains ordinary next-step ownership and receives fresh context when that later step enters; time-context adds no inbox state or AgentLoop lifecycle path.

View File

@@ -25,24 +25,33 @@ export const inject = ['invariants']
function preparationPosition(history: readonly SessionEvent[], fail: InvariantFailure): { turn: number; step: number } {
let openTurn: number | undefined
let openStep: number | undefined
let requestStarted = false
for (const event of history) {
switch (event.type) {
case 'turn/start': {
openTurn = event.data.turn
openStep = undefined
requestStarted = false
break
}
case 'step/start': {
openStep = event.data.step
requestStarted = false
break
}
case 'request/header': {
requestStarted = true
break
}
case 'step/end': {
openStep = undefined
requestStarted = false
break
}
case 'turn/end': {
openTurn = undefined
openStep = undefined
requestStarted = false
break
}
default:
@@ -51,6 +60,7 @@ function preparationPosition(history: readonly SessionEvent[], fail: InvariantFa
}
if (openTurn === undefined) fail('time-context reading must be appended inside an open turn')
if (openStep === undefined) fail('time-context reading must follow step/start')
if (requestStarted) fail('time-context reading must precede request/header')
return { turn: openTurn, step: openStep }
}

View File

@@ -102,6 +102,18 @@ describe('time-context invariants', () => {
}).not.toThrow()
})
it('rejects a reading appended after request execution starts', async () => {
const ctx = await setup()
const session = preparing(1, 1)
session.append('request/header', {
header: { config: { provider: 'mock', model: 'mock' } },
reason: 'initial',
})
expect(() => {
ctx.emit('session/event', session, event(reading()))
}).toThrow(/must precede request\/header/)
})
it('derives Session and client zones from their original durable owners', async () => {
const ctx = await setup()
const id = SessionId('time-invariant-zones')

View File

@@ -95,7 +95,7 @@ export const sessionSearchValueSchema = z.object({
hasMore: z.boolean(),
}) satisfies z.ZodType<Wire<ResponseValue<'session.search'>>>
/** session.create request payload (at most one of workspaceId / cwd). */
/** session.create payload; timeZone stays schema-optional so Host omission returns `invalid-time-zone`. */
export const sessionCreateRequestSchema = z.object({
workspaceId: workspaceIdSchema.optional(),
cwd: z.string().optional(),
@@ -247,7 +247,7 @@ export const sessionSelectModelValueSchema = z.object({
/** ContentBlock passthrough: core is merge-extensible — the type discriminant envelope is strict, the rest stays wide. */
export const contentBlockSchema = z.looseObject({ type: z.string() })
/** session.prompt request payload. */
/** session.prompt payload; clientTimeZone stays schema-optional so Host omission returns `invalid-time-zone`. */
export const sessionPromptRequestSchema = z.object({
sessionId: sessionIdSchema,
mode: z.union([z.literal('queue'), z.literal('steer')]),

View File

@@ -215,6 +215,7 @@ export interface SessionsApi {
workspaceId?: WorkspaceId
cwd?: string
sessionId?: SessionId
/** Required by the Host; optional here so omission returns the stable `invalid-time-zone` RPC error. */
timeZone?: string
}>):
Promise<RpcResponse<{ sessionId: SessionId }>>
@@ -300,6 +301,7 @@ export interface SessionsApi {
sessionId: SessionId
mode: 'queue' | 'steer'
content: ContentBlock[]
/** Required by the Host; optional here so omission returns the stable `invalid-time-zone` RPC error. */
clientTimeZone?: string
}>):
Promise<RpcResponse<{ accepted: true; command?: { kind: 'success'; text?: string } }>>

View File

@@ -549,6 +549,51 @@ describe('cold Session zone identity', () => {
})
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', () => {