fix: commit step context before request dispatch

This commit is contained in:
_Kerman
2026-07-30 18:18:04 +08:00
parent a31331cb0e
commit a4ae0b4126
17 changed files with 132 additions and 228 deletions

View File

@@ -174,6 +174,6 @@ export function apply(ctx: Context, config: Config): void {
const previous = step === 1
? precedingMessageTime(agent)
: precedingStepContextTime(agent, turn)
agent.inject(createUserMessage({ content: [{ type: 'text', text: renderText(now, turn, step, previous, formatter, resolvedTimeZone) }], source: { kind: 'plugin', plugin: name } }))
agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: renderText(now, turn, step, previous, formatter, resolvedTimeZone) }], source: { kind: 'plugin', plugin: name } }), { surfaceOp: 'append' })
}, { prepend: true })
}

View File

@@ -18,31 +18,22 @@ export const name = 'time-context-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** Derive the pre-step position at which a time-context reading may append. */
/** Derive the open step in which a time-context reading may append. */
function preparationPosition(history: readonly SessionEvent[], fail: InvariantFailure): { turn: number; step: number } {
const currentTurnEvents: SessionEvent[] = []
let openTurn: number | undefined
for (const event of history.slice().reverse()) {
if (event.type === 'turn/end') {
fail('time-context reading must be appended inside an open turn')
}
if (event.type === 'turn/start') {
openTurn = event.data.turn
break
}
currentTurnEvents.push(event)
}
if (openTurn === undefined) fail('time-context reading must be appended inside an open turn')
for (const event of currentTurnEvents) {
if (event.type === 'step/start') {
fail(`time-context reading must precede step/start, but step ${event.data.step} is already open`)
}
if (event.type === 'step/end') {
return { turn: openTurn, step: event.data.step + 1 }
switch (event.type) {
case 'step/start':
return event.data
case 'step/end':
case 'turn/start':
case 'turn/end':
fail('time-context reading must be appended inside an open step')
break
default:
break
}
}
return { turn: openTurn, step: 1 }
fail('time-context reading must be appended inside an open step')
}
/** Validate one plugin-attributed time reading against its session position and timestamp. */

View File

@@ -58,6 +58,7 @@ function preparing(turn: number, step: number): Session {
session.append('step/start', { turn, step: priorStep })
session.append('step/end', { turn, step: priorStep })
}
session.append('step/start', { turn, step })
return session
}
@@ -92,8 +93,8 @@ describe('time-context invariants', () => {
content: [{ type: 'text', text: 'prepare' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
appendReading(session, reading())
session.append('step/start', { turn: 1, step: 1 })
appendReading(session, reading())
await ctx.plugin(InvariantService, { enabled: true })
await expect(ctx.plugin(TimeInvariant)).resolves.toBeDefined()
@@ -108,6 +109,7 @@ describe('time-context invariants', () => {
content: [{ type: 'text', text: 'prepare' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('step/start', { turn: 1, step: 1 })
appendReading(session, reading('1', '2', 'step context'))
await ctx.plugin(InvariantService, { enabled: true })
@@ -127,17 +129,17 @@ describe('time-context invariants', () => {
const session = preparing(1, 2)
session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } })
expect(() => { ctx.emit('session/event', session, event(reading('1', '2', 'step context'))) })
.toThrow(/inside an open turn/)
.toThrow(/inside an open step/)
})
it('rejects a reading after step/start or without any open turn', async () => {
it('rejects a reading outside an open step', async () => {
const ctx = await setup()
const started = preparing(1, 1)
started.append('step/start', { turn: 1, step: 1 })
expect(() => { ctx.emit('session/event', started, event(reading())) }).toThrow(/must precede step\/start/)
const ended = preparing(1, 1)
ended.append('step/end', { turn: 1, step: 1 })
expect(() => { ctx.emit('session/event', ended, event(reading())) }).toThrow(/inside an open step/)
expect(() => {
ctx.emit('session/event', new Session(SessionId('time-invariant-empty')), event(reading()))
}).toThrow(/inside an open turn/)
}).toThrow(/inside an open step/)
})
it.each([

View File

@@ -54,7 +54,7 @@ describe('time-context through a real headless cordis.yml', () => {
expect(contexts).toHaveLength(2)
expect(starts).toHaveLength(2)
for (let index = 0; index < contexts.length; index += 1) {
expect(contexts[index]!.seq).toBeLessThan(starts[index]!.seq)
expect(contexts[index]!.seq).toBeGreaterThan(starts[index]!.seq)
expect(contexts[index]!.surfaceOp).toBe('append')
expect(contexts[index]!.data.source).toEqual({ kind: 'plugin', plugin: 'time-context' })
}

View File

@@ -45,9 +45,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
ctx: new Context(),
followup: () => {},
steer: () => {},
inject(input) {
session.append('user/message', input, { surfaceOp: 'append' })
},
inject: () => { throw new Error('time-context must append directly to the open step') },
cancel() {},
whenIdle: () => Promise.resolve(),
}
@@ -358,7 +356,7 @@ describe('real agent-loop request history', () => {
it.each([
['throws', 'error'],
['cancels', 'aborted'],
] as const)('discards the pending preparation reading when a later step listener %s', async (mode, reasonKind) => {
] as const)('retains the durable preparation reading when a later step listener %s', async (mode, reasonKind) => {
const adapter = new ScriptedAdapter([textResponse('unused')])
const ctx = await loopHarness(adapter)
let laterSawReading = false
@@ -372,10 +370,10 @@ describe('real agent-loop request history', () => {
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'start' }], source: { kind: 'user' } }))
await agent.whenIdle()
expect(laterSawReading).toBe(false)
expect(contextTexts(agent.session)).toHaveLength(0)
expect(laterSawReading).toBe(true)
expect(contextTexts(agent.session)).toHaveLength(1)
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false)
expect(agent.session.events.some(event => event.type === 'step/start')).toBe(true)
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe(reasonKind)
await ctx.fiber.dispose()
@@ -405,7 +403,7 @@ describe('real agent-loop request history', () => {
expect(contexts).toHaveLength(adapter.requests.length)
expect(starts).toHaveLength(adapter.requests.length)
for (let index = 0; index < contexts.length; index += 1) {
expect(contexts[index]!.seq).toBeLessThan(starts[index]!.seq)
expect(contexts[index]!.seq).toBeGreaterThan(starts[index]!.seq)
}
expect(contexts.every(event => event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'time-context'

View File

@@ -233,9 +233,9 @@ export function apply(ctx: Context, config: Config): void {
if (location === undefined) return
const state = renderState(location)
if (previous !== undefined && previous.state === state) return
agent.inject(createUserMessage({
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: renderReading(location, turn) }],
source: { kind: 'plugin', plugin: name },
}))
}), { surfaceOp: 'append' })
}, { prepend: true })
}

View File

@@ -101,9 +101,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
ctx: new Context(),
followup: () => {},
steer: () => {},
inject(input) {
session.append('user/message', input, { surfaceOp: 'append' })
},
inject: () => { throw new Error('tmux-context must append directly to the open step') },
cancel() {},
whenIdle: () => Promise.resolve(),
}

View File

@@ -116,20 +116,20 @@ export function apply(ctx: Context, config: Config): void {
{ includeBaselineScopes: false, signal },
)
if (update !== undefined) {
agent.inject(update.context)
agent.session.append('user/message', update.context, { surfaceOp: 'append' })
applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions)
}
const keepVisibleBaseline = !lifecycleWitnessed.has(agent.session) && hasVisibleBaseline(agent)
if (!keepVisibleBaseline && instructions !== undefined && instructions.rendered.text.length > 0) {
const baselineMessage = workspaceContextMessage(instructions.rendered.text)
agent.inject(createUserMessage({
agent.session.append('user/message', createUserMessage({
content: baselineMessage.content,
source: {
kind: 'workspace-instructions',
baseline: true,
changes: [...baseline.changes.values()],
},
}))
}), { surfaceOp: 'append' })
}
baselineLoaded.add(agent.session)
})

View File

@@ -180,9 +180,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
status: 'idle',
followup: () => {},
steer: () => {},
inject(input) {
session.append('user/message', input, { surfaceOp: 'append' })
},
inject: () => { throw new Error('workspace-context must append directly to the open step') },
cancel() {},
whenIdle: () => Promise.resolve(),
}
@@ -1164,7 +1162,7 @@ describe('workspace context request injection', () => {
const ctx = new Context()
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
ctx.on('agent/step', (agent) => {
agent.inject(createUserMessage({ content: [{ type: 'text', text: '<system-reminder>Available skills</system-reminder>' }], source: { kind: 'plugin', plugin: 'test-skills' } }))
agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: '<system-reminder>Available skills</system-reminder>' }], source: { kind: 'plugin', plugin: 'test-skills' } }), { surfaceOp: 'append' })
})
const prefix = await composeBaselinePrefix(ctx, stubAgent(root))