fix(agent): preserve lifecycle recovery boundaries (PR3 round 2)

This commit is contained in:
Hypatia May
2026-07-15 17:43:33 +08:00
parent dffe51dbf0
commit d66c926d7a
6 changed files with 151 additions and 42 deletions

View File

@@ -650,7 +650,8 @@ async function runStep(
session, turn, step, message.content, assembler.usage, chunkSeqs,
)
// Tool execution stays sequential; recheck abort around each normalized result.
// Tool execution stays sequential; cancellation latches synthetic results for
// every remaining call while preserving one complete result batch.
const toolCalls = message.content.filter(block => block.type === 'tool-call')
// Buffer context until all results are appended to preserve call/result adjacency.
const pendingContext: HookContext[] = []
@@ -693,9 +694,6 @@ async function runStep(
if (signal.aborted) aborted = true
}
/* v8 ignore next -- signal.reason always set by cancellation or disposal. */
if (aborted) throw new Error(String(signal.reason ?? 'aborted'))
// Append buffered context after the complete result batch.
for (const context of pendingContext) {
agent.inject(context.content, { source: context.source })

View File

@@ -156,7 +156,7 @@ describe('successful provider completion survives agent/step-result failure', ()
})
describe('abort during tool execution ends the turn', () => {
it('aborting the in-flight step inside a tool prevents both remaining tools and the next model step', async () => {
it('balances an aborted tool batch through context, steering, and post-step before closing', async () => {
const adapter = new MockAdapter([
// model asks for two tool calls in one step
[
@@ -175,8 +175,12 @@ describe('abort during tool execution ends the turn', () => {
name: 'aborter',
description: '',
parameters: {},
async execute() {
async execute(_args, exec) {
executed.push('aborter')
exec.agent?.steer(
[{ type: 'text', text: 'steering before abort' }],
{ source: { kind: 'plugin', plugin: 'abort-test' } },
)
// Fire the in-flight step's AbortController directly (the loop registers
// it on the agent). This is the bare step-abort path — distinct from
// cancel(), which would also clear the inbox; here the subject is the
@@ -185,6 +189,13 @@ describe('abort during tool execution ends the turn', () => {
return [{ type: 'text', text: 'done' }]
},
}))
ctx.on('tools/post-execute', async exec => ({
kind: 'accept',
additionalContext: {
content: [{ type: 'text', text: `context for ${exec.callId}` }],
source: { kind: 'plugin', plugin: 'abort-test' },
},
}))
ctx.tools.register(defineTool({
name: 'second',
description: '',
@@ -196,13 +207,53 @@ describe('abort during tool execution ends the turn', () => {
}))
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
const order: string[] = []
ctx.on('session/event', (session, event) => {
if (session !== agent.session) return
switch (event.type) {
case 'assistant/message': order.push('assistant/message'); break
case 'tool/call': order.push(`tool/call:${event.data.callId}`); break
case 'tool/result': {
const outcome = event.data.error?.code === 'ABORTED' ? 'synthetic-aborted' : 'real'
order.push(`tool/result:${event.data.callId}:${outcome}`)
break
}
case 'context/message': order.push('context/message'); break
case 'steering/message': order.push('steering/message'); break
case 'step/end': order.push('step/end'); break
case 'turn/end': {
reasons.push(event.data.reason)
order.push(`turn/end:${event.data.reason.kind}`)
break
}
}
})
let postSteps = 0
ctx.on('agent/post-step', (subject, turn, step, signal) => {
if (subject !== agent) return
postSteps += 1
expect({ turn, step, aborted: signal.aborted }).toEqual({ turn: 1, step: 1, aborted: true })
order.push('agent/post-step')
})
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(executed).toEqual(['aborter']) // second tool never ran
expect(adapter.requests).toHaveLength(1) // no follow-up model call
expect(postSteps).toBe(1)
expect(order).toEqual([
'assistant/message',
'tool/call:c1',
'tool/result:c1:real',
'tool/call:c2',
'tool/result:c2:synthetic-aborted',
'context/message',
'steering/message',
'agent/post-step',
'step/end',
'turn/end:aborted',
])
expect(reasons).toEqual([{ kind: 'aborted', reason: 'user interrupt' }])
const calls = agent.session.events.filter(event => event.type === 'tool/call')
const results = agent.session.events.filter(event => event.type === 'tool/result')