fix(acp): report end_turn for non-client turn aborts

TurnEndReasonMap aborted endings no longer map to 'cancelled': the ACP
spec reserves StopReason::Cancelled for the client's own session/cancel
notification (plus disposal, both settled out of band). A turn aborted by
a hook or another agent owner is ordinary quiescence and now settles as
end_turn, matching the README/note contract. interrupted keeps
'cancelled' as the crash-orphan marker.
This commit is contained in:
_Kerman
2026-08-03 21:07:23 +08:00
parent 97148182ed
commit 7454c1a5c2
3 changed files with 17 additions and 1 deletions

View File

@@ -17,7 +17,11 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason {
return 'end_turn'
case 'max-tokens':
return 'max_tokens'
// `cancelled` is reserved for explicit client cancellation (`session/cancel`)
// and disposal, both settled out of band; a turn aborted by a hook or
// another owner is ordinary quiescence and reports `end_turn`.
case 'aborted':
return 'end_turn'
case 'interrupted':
return 'cancelled'
case 'blocked':

View File

@@ -6,7 +6,7 @@ describe('ACP codec', () => {
it.each([
[{ kind: 'completed' }, 'end_turn'],
[{ kind: 'max-tokens' }, 'max_tokens'],
[{ kind: 'aborted', reason: { kind: 'user' } }, 'cancelled'],
[{ kind: 'aborted', reason: { kind: 'user' } }, 'end_turn'],
[{ kind: 'interrupted' }, 'cancelled'],
[{ kind: 'blocked' }, 'end_turn'],
[{ kind: 'error', error: { message: 'failed', code: 'UNKNOWN' } }, 'end_turn'],

View File

@@ -166,6 +166,18 @@ describe('ACP prompt lifecycle', () => {
.toEqual({ kind: 'aborted', reason: { kind: 'user' } })
})
it('settles a hook-cancelled turn as end_turn, not cancelled', async () => {
harness = await makeBridgeHarness({ script: ['hang'] })
const sessionId = await newSession(harness)
const prompt = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
const agent = harness.ctx.agents.get(SessionId(sessionId))!
await vi.waitFor(() => { expect(agent.status).toBe('running') })
// A hook or another owner cancels the agent: the ACP client never called
// session/cancel, so this is ordinary quiescence and reports end_turn.
agent.cancel({ kind: 'hook', reason: 'owner intervention' })
await expect(prompt).resolves.toEqual({ stopReason: 'end_turn' })
})
it('cancels autonomous running work without an in-flight prompt', async () => {
harness = await makeBridgeHarness({ script: ['hang'] })
const sessionId = await newSession(harness)