From 7d7d69e22512b10322659c247ea6a2069f974eca Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 20 Jul 2026 13:20:51 +0800 Subject: [PATCH] review fix: cover post-cancel replacement --- packages/core/agent-loop/src/loop.ts | 2 +- packages/core/agent-loop/tests/cancel.spec.ts | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 5e8168d895..d2a27394dc 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -133,7 +133,7 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise { if (handle.isDisposed()) break // Cancellation between wake and `running` skips only the cancelled work; - // a replacement prompt still runs and owns the eventual idle transition. + // a replacement prompt still runs before the eventual idle transition. if (handle.isCancelled()) { handle.clearCancel() if (!handle.inbox.hasQueued) { diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index c6d84dfbf7..39289779be 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -270,6 +270,31 @@ describe('Agent.cancel()', () => { expect(userTexts(agent)).toEqual(['first', 'later']) }) + it('replacement work queued after idle-listener cancellation still runs', async () => { + const adapter = new MockAdapter([textResponse('first reply'), textResponse('replacement reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('idle-listener-post-cancel-send'), { provider: 'mock', model: 'mock' }) + + const replacementRegistered = Promise.withResolvers() + let replacementIdle: Promise | undefined + ctx.on('agent/status', (subject, status) => { + if (subject !== agent || status !== 'idle' || replacementIdle !== undefined) return + send(agent, 'cancelled replacement') + agent.cancel('idle listener') + send(agent, 'surviving replacement') + replacementIdle = agent.whenIdle() + replacementRegistered.resolve(undefined) + }) + + send(agent, 'first') + await replacementRegistered.promise + if (replacementIdle === undefined) throw new Error('idle listener did not register replacement work') + await replacementIdle + + expect(adapter.requests).toHaveLength(2) + expect(userTexts(agent)).toEqual(['first', 'surviving replacement']) + }) + it('cancel() mid-step aborts the active turn and drops every queued tail item', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter)