From 1f130952a722c61c17c80bf4520e786a52f7236f Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Fri, 27 Mar 2026 17:38:18 -0600 Subject: [PATCH] fix(headless): skip execution_complete for multi-turn commands (auto/next) Multi-turn commands (auto, next) have their own completion signals via isTerminalNotification ("Auto-mode stopped..."/"Step-mode stopped..."). The execution_complete event fires after command setup before any real work begins, causing these commands to exit immediately with zero work done. Closes #2917 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/headless.ts | 5 +- src/tests/headless-v2-migration.test.ts | 74 ++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/src/headless.ts b/src/headless.ts index 624b887b6..492a73f63 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -255,6 +255,7 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number): // per-unit timeout via auto-supervisor. Disable the overall timeout unless the // user explicitly set --timeout. const isAutoMode = options.command === 'auto' + const isMultiTurnCommand = options.command === 'auto' || options.command === 'next' if (isAutoMode && options.timeout === 300_000) { options.timeout = 0 } @@ -571,7 +572,9 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number): } // Handle execution_complete (v2 structured completion) - if (eventObj.type === 'execution_complete' && !completed) { + // Skip for multi-turn commands (auto, next) — their completion is detected via + // isTerminalNotification("Auto-mode stopped..."/"Step-mode stopped..."), not per-turn events + if (eventObj.type === 'execution_complete' && !completed && !isMultiTurnCommand) { completed = true const status = String(eventObj.status ?? 'success') exitCode = mapStatusToExitCode(status) diff --git a/src/tests/headless-v2-migration.test.ts b/src/tests/headless-v2-migration.test.ts index cea747f40..1f233b710 100644 --- a/src/tests/headless-v2-migration.test.ts +++ b/src/tests/headless-v2-migration.test.ts @@ -132,6 +132,7 @@ interface EventHandlerState { blocked: boolean exitCode: number v2Enabled: boolean + isMultiTurnCommand?: boolean } function handleEvent( @@ -140,7 +141,9 @@ function handleEvent( client: MockRpcClient, ): void { // execution_complete (v2 structured completion) - if (eventObj.type === 'execution_complete' && !state.completed) { + // Skip for multi-turn commands (auto, next) — their completion is detected via + // isTerminalNotification, not per-turn events + if (eventObj.type === 'execution_complete' && !state.completed && !state.isMultiTurnCommand) { state.completed = true const status = String(eventObj.status ?? 'success') state.exitCode = mapStatusToExitCode(status) @@ -460,3 +463,72 @@ test('injector adapter handles multi-select values', () => { assert.equal(client.sendUICalls[0].id, 'inj3') assert.deepEqual(client.sendUICalls[0].response.values, ['a', 'b']) }) + +// ─── multi-turn command (auto/next) skips execution_complete ─────────────── + +test('execution_complete is ignored for multi-turn commands (auto)', () => { + const client = new MockRpcClient() + const state: EventHandlerState = { completed: false, blocked: false, exitCode: -1, v2Enabled: true, isMultiTurnCommand: true } + + handleEvent({ type: 'execution_complete', status: 'success' }, state, client) + + assert.equal(state.completed, false, 'should not mark completed for auto/next commands') + assert.equal(state.exitCode, -1, 'exit code should remain unchanged') +}) + +test('execution_complete is ignored for multi-turn commands even with error status', () => { + const client = new MockRpcClient() + const state: EventHandlerState = { completed: false, blocked: false, exitCode: -1, v2Enabled: true, isMultiTurnCommand: true } + + handleEvent({ type: 'execution_complete', status: 'error' }, state, client) + + assert.equal(state.completed, false, 'should not mark completed for auto/next commands') + assert.equal(state.exitCode, -1, 'exit code should remain unchanged') +}) + +test('multi-turn commands still complete via terminal notification', () => { + const client = new MockRpcClient() + const state: EventHandlerState = { completed: false, blocked: false, exitCode: -1, v2Enabled: true, isMultiTurnCommand: true } + + // First, execution_complete fires (should be ignored) + handleEvent({ type: 'execution_complete', status: 'success' }, state, client) + assert.equal(state.completed, false, 'execution_complete should be skipped') + + // Then the real terminal notification fires + handleEvent( + { type: 'extension_ui_request', method: 'notify', id: 'n1', message: 'Auto-mode stopped — all slices complete' }, + state, + client, + ) + assert.equal(state.completed, true, 'terminal notification should trigger completion') + assert.equal(state.exitCode, EXIT_SUCCESS) +}) + +test('multi-turn commands detect blocked via terminal notification', () => { + const client = new MockRpcClient() + const state: EventHandlerState = { completed: false, blocked: false, exitCode: -1, v2Enabled: true, isMultiTurnCommand: true } + + // execution_complete is ignored + handleEvent({ type: 'execution_complete', status: 'success' }, state, client) + assert.equal(state.completed, false) + + // Blocked terminal notification + handleEvent( + { type: 'extension_ui_request', method: 'notify', id: 'n2', message: 'Auto-mode stopped (Blocked: plan rejected)' }, + state, + client, + ) + assert.equal(state.completed, true) + assert.equal(state.blocked, true) + assert.equal(state.exitCode, EXIT_BLOCKED) +}) + +test('non-multi-turn commands still complete on execution_complete', () => { + const client = new MockRpcClient() + const state: EventHandlerState = { completed: false, blocked: false, exitCode: -1, v2Enabled: true, isMultiTurnCommand: false } + + handleEvent({ type: 'execution_complete', status: 'success' }, state, client) + + assert.equal(state.completed, true, 'single-turn commands should complete on execution_complete') + assert.equal(state.exitCode, EXIT_SUCCESS) +})