fix(agent-loop): publish turn state only after turn/start commits
A pre-commit turn/start rejection previously left the machine bricked:
lastTurn had already advanced past a turn the log never recorded, so every
later turn/start violated the session invariant's contiguity rule, and the
admitted prompt lingered in the outbox to leak into the next turn's request.
Keep admitted input on the stack (an argument to run()) until turn/start
commits, then advance lastTurn, set turnOpen, and append the prompt and its
additional contexts as user/message events inside the now-existing turn.
A rejected turn/start therefore unwinds with zero shared state to roll
back, the turn number stays reusable, and the outbox never holds input for
a turn that does not exist. This also restores the documented event order:
the prompt follows turn/start directly instead of waiting in the outbox
behind any steering carried over by cancel({keepInbox}).
This commit is contained in:
@@ -736,6 +736,46 @@ describe('turn and step boundary recovery', () => {
|
||||
expect(stepEndIdx).toBeLessThan(turnEndIdx)
|
||||
})
|
||||
|
||||
it('a pre-commit turn/start rejection leaves no turn state for the next prompt', async () => {
|
||||
const adapter = new MockAdapter([textResponse('after recovery')])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a-turnstart-veto'), { provider: 'mock', model: 'mock' })
|
||||
let rejected = false
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
if (name !== 'session/event') return
|
||||
const event = args[1] as SessionEvent
|
||||
if (event.type === 'turn/start' && !rejected) {
|
||||
rejected = true
|
||||
throw new Error('reject turn-start before commit')
|
||||
}
|
||||
})
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => {
|
||||
if (error instanceof Error) errors.push(error)
|
||||
})
|
||||
|
||||
send(agent, 'rejected')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// The rejected turn left nothing behind: no events, no admitted prompt.
|
||||
expect(agent.session.events).toEqual([])
|
||||
expect(errors.map(error => error.message)).toEqual(['reject turn-start before commit'])
|
||||
|
||||
// The next prompt reuses the never-committed turn number and carries only
|
||||
// its own admitted content — invariants (mounted) accept the log.
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(boundaryCounts(agent)).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1 })
|
||||
const turnStart = agent.session.events.find(event => event.type === 'turn/start')
|
||||
expect(turnStart?.type === 'turn/start' && turnStart.data.turn).toBe(1)
|
||||
const prompts = agent.session.events.filter(event => event.type === 'user/message')
|
||||
expect(prompts.map(event => event.type === 'user/message' && event.data.content)).toEqual([
|
||||
[{ type: 'text', text: 'go' }],
|
||||
])
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('a pre-commit step/start validation failure does not invent a step boundary', async () => {
|
||||
const adapter = new MockAdapter([textResponse('never reached')])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
|
||||
@@ -132,10 +132,12 @@ describe('thrown-value propagation', () => {
|
||||
const ends = agent.session.events.filter(event => event.type === 'turn/end')
|
||||
const messages = agent.session.events.filter(event => event.type === 'user/message')
|
||||
expect(starts).toHaveLength(1)
|
||||
expect(starts[0]?.type === 'turn/start' && starts[0].data.turn).toBe(2)
|
||||
// The rejected turn/start committed nothing, so the survivor reuses turn 1
|
||||
// and the rejected prompt does not leak into it.
|
||||
expect(starts[0]?.type === 'turn/start' && starts[0].data.turn).toBe(1)
|
||||
expect(ends).toHaveLength(1)
|
||||
expect(messages).toHaveLength(2)
|
||||
expect(messages[1]?.type === 'user/message' && messages[1].data.content).toEqual([
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(messages[0]?.type === 'user/message' && messages[0].data.content).toEqual([
|
||||
{ type: 'text', text: 'survives as the next item' },
|
||||
])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user