fix(time-context): close request-boundary gaps
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user