feat(agent): add queue-aware Agent.cancel() primitive

abort() only kills the in-flight step, so a queued-but-not-yet-started prompt
ran to completion after a cancel and a prompt accepted right after could be
batched into the cancelled turn (the loop merges queued messages into one turn).
This closes TODO(rfc010-cancel-prestep) with a distinct cancel() verb.

cancel() clears the queued + steering FIFOs, aborts the in-flight step, and
drives a turn-scoped marker on the LoopHandle that the driver checks at EVERY
point a turn could start or continue:
- right after the idle wait (window 1): drop the about-to-run turn and settle
  whenIdle() waiters directly (no running→idle transition fires, and no
  agent/status is emitted, so an ACP listener can't see a spurious idle that
  resolves a freshly-queued prompt as cancelled);
- after the synchronous setStatus('running') emit (window 2): a running listener
  can cancel in the gap before runTurn;
- in the step-start window (before runStep, after setAbort): a synchronous
  turn-start/step-start listener can cancel before any AbortController exists;
- at the continuation gate: a cancel during the continuation waterfall (the
  finished step's controller already cleared) ends the turn aborted.

The marker is ARMED only when there is something to cancel (running, an
in-flight step, or queued/steering work) — an idle no-op cancel cannot leave it
set to drop a later prompt — and RESET unconditionally once per loop iteration,
so it governs exactly one turn and never leaks onto the next prompt (even when a
send() lands in the cancelled turn's flush window).

ACP session/cancel now maps to agent.cancel() (keeping the synchronous
settlePrompt). Teardown/disconnect still use abort('disposed') until PR D, so
the ACP README narrows the remaining best-effort window to teardown only.

Tests (agent-loop/cancel.spec.ts) cover every window unit-level (the F1 hang
guard: a whenIdle() waiter registered before a pre-step cancel resolves; the F2
leak guard: idle cancel then a prompt runs; mid-step, continuation, both
pre-step windows, turn-start-listener, steering-cleared, marker-reset). ACP
turns.spec.ts adds the through-bridge tests with NO intervening whenIdle (idle
cancel→prompt runs; mid-stream cancel→immediate next prompt runs) and updates
the stale pre-step test to the queue-aware guarantee. The existing cancel
snapshot golden is byte-identical (it drives the new cancel() path end-to-end
through the real subprocess), so no new golden is needed. 100% coverage.
This commit is contained in:
Tianyi Cui
2026-06-20 04:51:32 +08:00
parent 3d67a98291
commit c4bc6e0e38
12 changed files with 504 additions and 31 deletions
+53 -10
View File
@@ -327,21 +327,64 @@ describe('acp bridge — turn outcomes', () => {
expect(res.stopReason).toBe('cancelled')
})
it('cancel in the pre-step window still settles the prompt cancelled exactly once', async () => {
// No script entry is consumed before cancel: cancel immediately after the
// prompt is sent, before the model step starts. The prompt must still
// settle cancelled (best-effort abort + settle), not hang.
harness = await makeBridgeHarness({ storageDir, script: [textResponse('late')] })
it('cancel right after prompt settles cancelled and leaves the agent idle, no leaked turn', async () => {
// Over the async JSON-RPC transport the loop usually wakes before cancel
// arrives, so this is a running/mid-step cancel (the synchronous pre-step
// DROP is unit-tested in agent-loop/cancel.spec.ts). The ACP-level guarantee:
// the prompt settles cancelled, the agent reaches idle, and no second/leaked
// turn runs afterward.
harness = await makeBridgeHarness({ storageDir, script: [textResponse('answer'), textResponse('leaked')] })
const sessionId = await newSession(harness)
const promptDone = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
await harness.client.cancel({ sessionId })
const res = await promptDone
expect(res.stopReason).toBe('cancelled')
// The queued turn may still start after the cancel cleared the in-flight
// slot (the documented TODO(rfc010-cancel-prestep) best-effort window): its
// turn-start then fires with no prompt to tag, and the bridge does nothing.
// Let it run to completion and assert nothing re-settles (no throw, no hang).
await harness.ctx.agents.get(sessionId)!.whenIdle()
const agent = harness.ctx.agents.get(sessionId)!
await agent.whenIdle()
// At most ONE turn ran (the cancelled one) — the cancel cleared the queue, so
// no second turn was batched or leaked. (A best-effort abort that left queued
// work could have started a second turn.)
const turnStarts = agent.session.events.filter(e => e.type === 'turn/start').length
expect(turnStarts).toBeLessThanOrEqual(1)
})
it('idle session/cancel then session/prompt runs the prompt (no intervening whenIdle)', async () => {
// The ACP bridge settles the cancel RPC synchronously and accepts the next
// prompt WITHOUT awaiting quiescence — so this drives cancel→prompt with NO
// whenIdle() between, the production race. An idle cancel must be a no-op that
// does NOT drop the following prompt.
harness = await makeBridgeHarness({ storageDir, script: [textResponse('real answer')] })
const sessionId = await newSession(harness)
// Cancel while idle (no prompt in flight) — a no-op.
await harness.client.cancel({ sessionId })
// Immediately prompt, no whenIdle() between.
const res = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
expect(res.stopReason).toBe('end_turn')
const text = harness.updates
.filter(u => u.sessionUpdate === 'agent_message_chunk')
.map(u => (u.content.type === 'text' ? u.content.text : ''))
.join('')
expect(text).toContain('real answer')
})
it('mid-stream cancel then an IMMEDIATE next prompt runs (no intervening whenIdle)', async () => {
// Cancel a running turn, then send the next prompt WITHOUT awaiting quiescence
// (the synchronous-settle path). The new prompt must run — the cancel marker
// must not leak onto it.
harness = await makeBridgeHarness({ storageDir, script: ['hang', textResponse('next answer')] })
const sessionId = await newSession(harness)
const a = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'A' }] })
await new Promise(r => setTimeout(r, 30))
await harness.client.cancel({ sessionId })
expect((await a).stopReason).toBe('cancelled')
// Immediately — no whenIdle() — send the next prompt.
const b = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'B' }] })
expect(b.stopReason).toBe('end_turn')
const text = harness.updates
.filter(u => u.sessionUpdate === 'agent_message_chunk')
.map(u => (u.content.type === 'text' ? u.content.text : ''))
.join('')
expect(text).toContain('next answer')
})
it('a cancelled turn\'s late turn/end does not settle the NEXT prompt', async () => {