From 7454c1a5c2f0f27886a264b3d9cc6cf456a1ed40 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 3 Aug 2026 21:07:23 +0800 Subject: [PATCH] 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. --- packages/acp/acp/src/codec.ts | 4 ++++ packages/acp/acp/tests/codec.spec.ts | 2 +- packages/acp/acp/tests/turns.spec.ts | 12 ++++++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/acp/acp/src/codec.ts b/packages/acp/acp/src/codec.ts index 44911bb949..9fcdb68f7b 100644 --- a/packages/acp/acp/src/codec.ts +++ b/packages/acp/acp/src/codec.ts @@ -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': diff --git a/packages/acp/acp/tests/codec.spec.ts b/packages/acp/acp/tests/codec.spec.ts index 0317898e22..335ead9798 100644 --- a/packages/acp/acp/tests/codec.spec.ts +++ b/packages/acp/acp/tests/codec.spec.ts @@ -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'], diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index fd03e03e8d..b88b877c3a 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -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)