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)

View File

@@ -44,7 +44,7 @@ The handle every plugin programs against:
- `agent.send(content, options?)` — queue a message; starts a turn when idle. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content).
- `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle
- `agent.inject(content, options?)` — inject in-session context (context/message event); the next request sees it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md))
- `agent.cancel(cause?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the active turn, and drops queued work not yet claimed by the driver. `AgentCancelCause` is the runtime-only `{ kind: 'user' } | { kind: 'parent' }`; omission means `user`, the first cause wins for an active turn, and ACP `session/cancel` maps to `user`. `normalizeAgentCancelCause()` provides the same strict detached-value boundary used by the concrete loop: validation is synchronous even while idle, accepts only an exact plain object, and returns a safe no-op when no work exists.
- `agent.cancel(cause?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the active turn, and drops queued work not yet claimed by the driver. `AgentCancelCause` is the runtime-only `{ kind: 'user' } | { kind: 'parent' }`; omission means `user`, the first cause wins for an active turn, and ACP `session/cancel` maps to `user`. `normalizeAgentCancelCause()` provides the same strict detached-value boundary used by the concrete loop: validation is synchronous even while idle, accepts only an exact plain object, and returns a frozen detached cause. After validation, `agent.cancel()` is a safe no-op when no work exists.
- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly.
- `agent.session`, `agent.status`, `agent.options`, `agent.id`