fix(agent-loop): latch wakes landing in the cancel-convergence window

This commit is contained in:
_Kerman
2026-08-07 14:54:31 +08:00
committed by Tianyi Cui
parent 22609ea425
commit df30c62e2b
23 changed files with 379 additions and 77 deletions

View File

@@ -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 () => {