fix(core): preserve abort-observer replacements

This commit is contained in:
Yichen Jiang
2026-07-16 19:16:54 +08:00
parent c238992fbb
commit 55916f931d
6 changed files with 63 additions and 28 deletions

View File

@@ -276,12 +276,13 @@ export class ReactLoopAgent implements Agent {
const active = this.turnCancellation
if (active === undefined && !this.#inbox.hasQueued && !this.#inbox.hasSteering) return
if (active === undefined) this.preRunCancelled = true
else active.request(accepted)
// Drop all pending queued + steering work (un-started prompts never run; the
// cancelled turn's steering is not re-enqueued). Cleared directly even when
// the loop is parked in waitForQueued — there is no turn to stop and nothing
// left for the parked loop to run, so no wake is needed.
// cancelled turn's steering is not re-enqueued). Clear before abort dispatch,
// whose synchronous observers may enqueue replacement work that must survive.
// This is direct even when the loop is parked in waitForQueued — there is no
// turn to stop and nothing left for the parked loop to run, so no wake is needed.
this.#inbox.clear()
if (active !== undefined) active.request(accepted)
}
/**

View File

@@ -124,6 +124,40 @@ describe('Agent.cancel()', () => {
expect(reasons).toEqual([{ kind: 'aborted' }])
})
it('keeps replacement work queued synchronously by an abort observer', async () => {
const adapter = new MockAdapter(['hang', textResponse('replacement reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('abort-observer-replacement'), { model: 'mock' })
send(agent, 'original')
await expect.poll(() => adapter.requests.length).toBe(1)
const signal = adapter.requests[0]?.signal
if (signal === undefined) throw new Error('model request omitted its turn signal')
signal.addEventListener('abort', () => { send(agent, 'replacement') }, { once: true })
const idle = waitForIdle(ctx, agent)
agent.cancel({ kind: 'user' })
await Promise.race([
idle,
new Promise((_resolve, reject) => {
setTimeout(() => {
reject(new Error(`replacement did not settle: ${JSON.stringify({
status: agent.status,
requests: adapter.requests.length,
users: userTexts(agent),
events: agent.session.events.map(event => event.type),
})}`))
}, 1000)
}),
])
expect(adapter.requests).toHaveLength(2)
expect(userTexts(agent)).toEqual(['original', 'replacement'])
const reasons = agent.session.events
.filter(event => event.type === 'turn/end')
.map(event => event.type === 'turn/end' ? event.data.reason : undefined)
expect(reasons).toEqual([{ kind: 'aborted' }, { kind: 'completed' }])
})
it('cancel() with no cause defaults to user when aborting an active turn', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)