fix(agent-loop): latch wakes landing in the cancel-convergence window
This commit is contained in:
@@ -41,6 +41,14 @@ function send(agent: Agent, text: string) {
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }))
|
||||
}
|
||||
|
||||
/** All user-message texts recorded in the log (to assert what actually ran). */
|
||||
function userTexts(agent: Agent): string[] {
|
||||
return agent.session.events
|
||||
.filter(e => e.type === 'user/message')
|
||||
.flatMap(e => e.type === 'user/message' ? e.data.content : [])
|
||||
.flatMap(b => b.type === 'text' ? [b.text] : [])
|
||||
}
|
||||
|
||||
describe('agent loop', () => {
|
||||
it.each([0, -1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 1])(
|
||||
'rejects invalid AgentOptions.maxTokens %s before publication',
|
||||
@@ -70,7 +78,7 @@ describe('agent loop', () => {
|
||||
})
|
||||
|
||||
it('cancels queued wakeup work together with an active maintenance task', async () => {
|
||||
const adapter = new MockAdapter([textResponse('unused')])
|
||||
const adapter = new MockAdapter([textResponse('park reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('cancel-maintenance-wakeup'), {
|
||||
provider: 'mock',
|
||||
@@ -87,15 +95,68 @@ describe('agent loop', () => {
|
||||
})
|
||||
await started.promise
|
||||
|
||||
send(agent, 'discard this wakeup')
|
||||
agent.cancel({ kind: 'user' })
|
||||
send(agent, 'park after cancellation')
|
||||
send(agent, 'discard this wakeup') // latched behind the live maintenance task
|
||||
agent.cancel({ kind: 'user' }) // drops the queue and the latch, aborts maintenance
|
||||
send(agent, 'park after cancellation') // newer intent: re-latched, replays at convergence
|
||||
|
||||
await expect(maintenance).rejects.toThrow('maintenance aborted')
|
||||
await agent.whenIdle()
|
||||
expect(agent.inbox.nextTurn).toHaveLength(1)
|
||||
|
||||
// The pre-cancel wakeup is gone; the post-cancel wake replays at convergence.
|
||||
expect(userTexts(agent)).toEqual(['park after cancellation'])
|
||||
expect(agent.inbox.nextTurn).toHaveLength(0)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('replays a wake latched behind maintenance at convergence', async () => {
|
||||
const adapter = new MockAdapter([textResponse('wake reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('maintenance-wake-replay'), {
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
})
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const finish = Promise.withResolvers<undefined>()
|
||||
const maintenance = agent.runMaintenance(async () => {
|
||||
started.resolve(undefined)
|
||||
await finish.promise
|
||||
})
|
||||
await started.promise
|
||||
|
||||
send(agent, 'wake behind maintenance')
|
||||
finish.resolve(undefined)
|
||||
await maintenance
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(userTexts(agent)).toEqual(['wake behind maintenance'])
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('suppresses the replay when a latched maintenance wake is removed', async () => {
|
||||
const adapter = new MockAdapter([])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('maintenance-wake-removed'), {
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
})
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const finish = Promise.withResolvers<undefined>()
|
||||
const maintenance = agent.runMaintenance(async () => {
|
||||
started.resolve(undefined)
|
||||
await finish.promise
|
||||
})
|
||||
await started.promise
|
||||
|
||||
const wake = createUserMessage({ content: [{ type: 'text', text: 'removed wake' }], source: { kind: 'user' } })
|
||||
agent.followup(wake)
|
||||
agent.inbox.remove(wake.id)
|
||||
finish.resolve(undefined)
|
||||
await maintenance
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(userTexts(agent)).toEqual([])
|
||||
expect(adapter.requests).toEqual([])
|
||||
agent.cancel({ kind: 'user' })
|
||||
expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('runs a simple turn: queued message → model → idle, with ordered events', async () => {
|
||||
|
||||
Reference in New Issue
Block a user