fix pre-step cancellation and compaction convergence

This commit is contained in:
Hypatia May
2026-06-30 10:56:34 +08:00
parent 6ae1e229fd
commit b0eae94fc8
5 changed files with 63 additions and 15 deletions

View File

@@ -432,16 +432,24 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
// pre-step plugin ends the turn, not the loop.
await ctx.serial('agent/pre-step', agent, turn, step, fullSystemPrompt, abort.signal)
// Interruption landing during the pre-step seam: do not open an empty
// step. `agent/step-start` listeners get their own check below because
// they necessarily run after step/start is appended/emitted.
if (handle.isCancelled() || handle.isDisposed()) {
handle.setAbort(undefined)
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
break
}
session.append('step/start', { turn, step })
stepOpen = true
ctx.emit('agent/step-start', agent, turn, step)
// Cancel landing in the seam / step-start window: a `cancel()` during the
// pre-step seam (it aborted `abort.signal` above) OR a synchronous
// `agent/step-start` listener that cancels. And disposal, which the earlier
// assembly check may have missed if it only checked isCancelled. Check
// AFTER step/start append + emit and before `runStep`: drop the step, end
// the turn accordingly. closeStep balances the already-appended step/start.
// Cancel landing in the step-start window: a synchronous
// `agent/step-start` listener can cancel after the step is already open.
// Check AFTER step/start append + emit and before `runStep`: drop the
// step, end the turn accordingly. closeStep balances the already-appended
// step/start.
if (handle.isCancelled() || handle.isDisposed()) {
handle.setAbort(undefined)
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }

View File

@@ -1216,6 +1216,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
const turnEnd = e.findLast(x => x.type === 'turn/end')
// Disposal wins the post-seam check — reason is `disposed`.
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' })
expect(e.some(x => x.type === 'step/start')).toBe(false)
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
// agent/turn-end may not fire when disposal happens during pre-step: the
// fiber's disposer runs before closeTurn(true)'s emit. The durable turn/end
@@ -1265,6 +1266,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
const turnEnd = e.findLast(x => x.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: 'user cancelled' })
expect(e.some(x => x.type === 'step/start')).toBe(false)
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
expect(reasons).toEqual([{ kind: 'aborted', reason: 'user cancelled' }])
})