From 857ae159085d17cff8079623e3a2079af8755ea7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:01:39 -0700 Subject: [PATCH] fix(agent-hooks): trust Claude Stop hooks for Escape --- ...errupt-inference-escape-navigation.test.ts | 54 +-------- .../server-interrupt-inference-guards.test.ts | 72 ++---------- ...r-interrupt-inference-resurrection.test.ts | 104 ++++-------------- ...ver-interrupt-inference-validation.test.ts | 61 ++-------- src/main/agent-hooks/server.ts | 88 ++++++--------- ...ay-session-agent-hooks.integration.test.ts | 21 +--- src/shared/agent-interrupt-hint.test.ts | 90 --------------- src/shared/agent-interrupt-hint.ts | 91 --------------- src/shared/agent-status-types.ts | 3 +- 9 files changed, 77 insertions(+), 507 deletions(-) delete mode 100644 src/shared/agent-interrupt-hint.test.ts delete mode 100644 src/shared/agent-interrupt-hint.ts diff --git a/src/main/agent-hooks/server-interrupt-inference-escape-navigation.test.ts b/src/main/agent-hooks/server-interrupt-inference-escape-navigation.test.ts index f594fadde18..051d9cedc62 100644 --- a/src/main/agent-hooks/server-interrupt-inference-escape-navigation.test.ts +++ b/src/main/agent-hooks/server-interrupt-inference-escape-navigation.test.ts @@ -30,7 +30,7 @@ function ingestWorking( server: AgentHookServer, payload: { prompt: string - agentType: 'claude' | 'codex' | 'opencode' + agentType: 'claude' hookEventName?: string } ): ReturnType[0] { @@ -52,10 +52,6 @@ function ingestWorking( return server.getStatusSnapshot()[0]! } -// Why: take the snapshot row type itself rather than a hand-written structural -// shape - the payload's fields are optional, so a narrower literal type does not -// accept it and typecheck fails (tests are excluded from the mobile tsconfig but -// not from this one). function inferEscape( server: AgentHookServer, baseline: ReturnType[0] @@ -80,6 +76,8 @@ describe('interrupt inference yields to a live working hook row', () => { vi.setSystemTime(1_500) expect(inferEscape(server, baseline)).toBe(false) + vi.setSystemTime(1_600) + expect(inferEscape(server, baseline)).toBe(false) expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working', prompt: 'long tool call', @@ -247,50 +245,4 @@ describe('interrupt inference yields to a live working hook row', () => { vi.useRealTimers() } }) - - it('stamps interrupted onto a lead done for agents that omit is_interrupt', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - const baseline = ingestWorking(server, { prompt: 'long task', agentType: 'opencode' }) - - vi.setSystemTime(1_500) - expect( - server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'long task', - baselineAgentType: 'opencode', - intent: 'plain-escape', - inputCount: 2 - }) - ).toBe(false) - expect(server.getStatusSnapshot()[0]).toMatchObject({ - state: 'working', - interrupted: undefined - }) - - vi.setSystemTime(1_501) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'done', prompt: 'long task', agentType: 'opencode' } - }, - 'conn-1' - ) - - expect(server.getStatusSnapshot()[0]).toMatchObject({ - state: 'done', - prompt: 'long task', - agentType: 'opencode', - interrupted: true - }) - } finally { - vi.useRealTimers() - } - }) }) diff --git a/src/main/agent-hooks/server-interrupt-inference-guards.test.ts b/src/main/agent-hooks/server-interrupt-inference-guards.test.ts index 0b40c552e7d..a48c5d18aa6 100644 --- a/src/main/agent-hooks/server-interrupt-inference-guards.test.ts +++ b/src/main/agent-hooks/server-interrupt-inference-guards.test.ts @@ -62,26 +62,7 @@ describe('AgentHookServer listener replay', () => { intent: 'plain-escape' }) - expect(applied).toBe(false) - expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) - - vi.setSystemTime(1_501) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - providerSession: { key: 'session_id', id: 'codex-interrupt-session-1' }, - payload: { - state: 'done', - prompt: 'long task', - agentType: 'codex', - model: 'gpt-5.6-sol' - } - }, - 'conn-1' - ) - + expect(applied).toBe(true) expect(server.getStatusSnapshot()).toEqual([ expect.objectContaining({ paneKey: PANE, @@ -90,8 +71,8 @@ describe('AgentHookServer listener replay', () => { agentType: 'codex', providerSession: { key: 'session_id', id: 'codex-interrupt-session-1' }, interrupted: true, - receivedAt: 1_501, - stateStartedAt: 1_501 + receivedAt: 1_500, + stateStartedAt: 1_500 }) ]) expect(listener).toHaveBeenLastCalledWith( @@ -348,8 +329,7 @@ describe('AgentHookServer listener replay', () => { baselineAgentType: 'claude', intent: 'ctrl-c' }) - ).toBe(false) - expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) + ).toBe(true) } finally { vi.useRealTimers() } @@ -423,27 +403,7 @@ describe('AgentHookServer listener replay', () => { intent: 'ctrl-c' }) - expect(applied).toBe(false) - expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) - - vi.setSystemTime(1_501) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hookEventName: 'Stop', - payload: { - state: 'done', - prompt: 'wrap up', - agentType: 'claude', - interrupted: true, - subagents: [{ id: 'a1', state: 'idle', startedAt: 900, agentType: 'probe1' }] - } - }, - 'conn-1' - ) - + expect(applied).toBe(true) expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', interrupted: true, @@ -483,8 +443,7 @@ describe('AgentHookServer listener replay', () => { intent: 'plain-escape', inputCount: 2 }) - ).toBe(false) - expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) + ).toBe(true) vi.setSystemTime(1_501) server.ingestRemote( @@ -504,8 +463,8 @@ describe('AgentHookServer listener replay', () => { prompt: 'long task', agentType: 'opencode', interrupted: true, - receivedAt: 1_501, - stateStartedAt: 1_501 + receivedAt: 1_500, + stateStartedAt: 1_500 }) ]) expect(listener).toHaveBeenLastCalledWith( @@ -637,20 +596,7 @@ describe('AgentHookServer listener replay', () => { inputCount: 2 }) - expect(applied).toBe(false) - expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working', agentType }) - - vi.setSystemTime(1_501) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'done', prompt: 'long task', agentType } - }, - 'conn-1' - ) - + expect(applied).toBe(true) expect(server.getStatusSnapshot()).toEqual([ expect.objectContaining({ state: 'done', diff --git a/src/main/agent-hooks/server-interrupt-inference-resurrection.test.ts b/src/main/agent-hooks/server-interrupt-inference-resurrection.test.ts index 787dad2083c..d8fd8ecffbf 100644 --- a/src/main/agent-hooks/server-interrupt-inference-resurrection.test.ts +++ b/src/main/agent-hooks/server-interrupt-inference-resurrection.test.ts @@ -53,18 +53,7 @@ describe('AgentHookServer listener replay', () => { baselineAgentType: 'pi', intent: 'ctrl-c' }) - ).toBe(false) - - vi.setSystemTime(1_501) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'done', prompt: 'long task', agentType: 'pi' } - }, - 'conn-1' - ) + ).toBe(true) vi.setSystemTime(6_000) server.ingestRemote( @@ -89,8 +78,8 @@ describe('AgentHookServer listener replay', () => { prompt: 'long task', agentType: 'pi', interrupted: true, - receivedAt: 1_501, - stateStartedAt: 1_501 + receivedAt: 1_500, + stateStartedAt: 1_500 }) ]) } finally { @@ -130,24 +119,7 @@ describe('AgentHookServer listener replay', () => { baselineAgentType: 'claude', intent: 'ctrl-c' }) - ).toBe(false) - - vi.setSystemTime(1_501) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hookEventName: 'Stop', - payload: { - state: 'done', - prompt: 'Do I have gpu acceleration on on my terminal?', - agentType: 'claude', - interrupted: true - } - }, - 'conn-1' - ) + ).toBe(true) vi.setSystemTime(2_000) server.ingestRemote( @@ -174,8 +146,8 @@ describe('AgentHookServer listener replay', () => { prompt: 'Do I have gpu acceleration on on my terminal?', agentType: 'claude', interrupted: true, - receivedAt: 1_501, - stateStartedAt: 1_501 + receivedAt: 1_500, + stateStartedAt: 1_500 }) ]) } finally { @@ -215,22 +187,7 @@ describe('AgentHookServer listener replay', () => { baselineAgentType: 'codex', intent: 'plain-escape' }) - ).toBe(false) - - vi.setSystemTime(1_501) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { - state: 'done', - prompt: 'Run sleep 30, then reply done.', - agentType: 'codex' - } - }, - 'conn-1' - ) + ).toBe(true) vi.setSystemTime(6_000) server.ingestRemote( @@ -257,8 +214,8 @@ describe('AgentHookServer listener replay', () => { prompt: 'Run sleep 30, then reply done.', agentType: 'codex', interrupted: true, - receivedAt: 1_501, - stateStartedAt: 1_501 + receivedAt: 1_500, + stateStartedAt: 1_500 }) ]) } finally { @@ -292,7 +249,7 @@ describe('AgentHookServer listener replay', () => { baselineAgentType: 'pi', intent: 'ctrl-c' }) - ).toBe(false) + ).toBe(true) vi.setSystemTime(2_000) server.ingestRemote( @@ -311,7 +268,7 @@ describe('AgentHookServer listener replay', () => { prompt: 'second task', agentType: 'pi', receivedAt: 2_000, - stateStartedAt: 1_000 + stateStartedAt: 2_000 }) ]) } finally { @@ -347,7 +304,7 @@ describe('AgentHookServer listener replay', () => { baselineAgentType: 'claude', intent: 'ctrl-c' }) - ).toBe(false) + ).toBe(true) vi.setSystemTime(2_000) server.ingestRemote( @@ -369,7 +326,7 @@ describe('AgentHookServer listener replay', () => { agentType: 'claude', interrupted: undefined, receivedAt: 2_000, - stateStartedAt: 1_000 + stateStartedAt: 2_000 }) ]) } finally { @@ -404,7 +361,7 @@ describe('AgentHookServer listener replay', () => { baselineAgentType: 'pi', intent: 'ctrl-c' }) - ).toBe(false) + ).toBe(true) vi.setSystemTime(2_000) server.ingestRemote( @@ -424,7 +381,7 @@ describe('AgentHookServer listener replay', () => { prompt: 'retryable task', agentType: 'pi', receivedAt: 2_000, - stateStartedAt: 1_000 + stateStartedAt: 2_000 }) ]) } finally { @@ -460,24 +417,7 @@ describe('AgentHookServer listener replay', () => { baselineAgentType: 'claude', intent: 'ctrl-c' }) - ).toBe(false) - - vi.setSystemTime(1_501) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hookEventName: 'Stop', - payload: { - state: 'done', - prompt: 'repeat task', - agentType: 'claude', - interrupted: true - } - }, - 'conn-1' - ) + ).toBe(true) vi.setSystemTime(16_501) server.ingestRemote( @@ -504,8 +444,8 @@ describe('AgentHookServer listener replay', () => { prompt: 'repeat task', agentType: 'claude', interrupted: true, - receivedAt: 1_501, - stateStartedAt: 1_501 + receivedAt: 1_500, + stateStartedAt: 1_500 }) ]) } finally { @@ -539,7 +479,7 @@ describe('AgentHookServer listener replay', () => { baselineAgentType: 'pi', intent: 'ctrl-c' }) - ).toBe(false) + ).toBe(true) vi.setSystemTime(16_501) server.ingestRemote( @@ -559,7 +499,7 @@ describe('AgentHookServer listener replay', () => { agentType: 'pi', interrupted: undefined, receivedAt: 16_501, - stateStartedAt: 1_000 + stateStartedAt: 16_501 }) ]) } finally { @@ -593,7 +533,7 @@ describe('AgentHookServer listener replay', () => { baselineAgentType: 'pi', intent: 'ctrl-c' }) - ).toBe(false) + ).toBe(true) vi.setSystemTime(16_501) server.ingestRemote( @@ -621,7 +561,7 @@ describe('AgentHookServer listener replay', () => { toolName: 'bash', toolInput: '/bin/sleep 90', receivedAt: 16_501, - stateStartedAt: 1_000 + stateStartedAt: 16_501 }) ]) } finally { diff --git a/src/main/agent-hooks/server-interrupt-inference-validation.test.ts b/src/main/agent-hooks/server-interrupt-inference-validation.test.ts index df8a72997ab..971da33b22c 100644 --- a/src/main/agent-hooks/server-interrupt-inference-validation.test.ts +++ b/src/main/agent-hooks/server-interrupt-inference-validation.test.ts @@ -127,7 +127,7 @@ describe('AgentHookServer listener replay', () => { baselineAgentType: 'opencode', intent: 'ctrl-c' }) - ).toBe(false) + ).toBe(true) vi.setSystemTime(2_000) server.ingestRemote( @@ -153,7 +153,7 @@ describe('AgentHookServer listener replay', () => { agentType: 'opencode', lastAssistantMessage: 'partial answer', receivedAt: 2_000, - stateStartedAt: 1_000 + stateStartedAt: 2_000 }) ]) } finally { @@ -193,23 +193,7 @@ describe('AgentHookServer listener replay', () => { baselineAgentType: 'opencode', intent: 'ctrl-c' }) - ).toBe(false) - - vi.setSystemTime(1_501) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { - state: 'done', - prompt: 'retryable task', - agentType: 'opencode', - lastAssistantMessage: 'partial answer' - } - }, - 'conn-1' - ) + ).toBe(true) vi.setSystemTime(20_000) server.ingestRemote( @@ -235,8 +219,8 @@ describe('AgentHookServer listener replay', () => { prompt: 'retryable task', agentType: 'opencode', interrupted: true, - receivedAt: 1_501, - stateStartedAt: 1_501 + receivedAt: 1_500, + stateStartedAt: 1_500 }) ]) } finally { @@ -270,19 +254,7 @@ describe('AgentHookServer listener replay', () => { baselineAgentType: 'unknown', intent: 'ctrl-c' }) - ).toBe(false) - expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) - - vi.setSystemTime(1_501) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: { state: 'done', prompt: 'custom hook' } - }, - 'conn-1' - ) + ).toBe(true) expect(server.getStatusSnapshot()).toEqual([ expect.objectContaining({ @@ -374,26 +346,7 @@ describe('AgentHookServer listener replay', () => { baselineAgentType: 'custom-agent', intent: 'ctrl-c' }) - ).toBe(false) - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: GOOD_PANE, - state: 'working', - prompt: 'custom task', - agentType: 'custom-agent' - }) - ]) - - vi.setSystemTime(1_251) - server.ingestRemote( - { - paneKey: GOOD_PANE, - tabId: 'tab-good', - worktreeId: 'wt-1', - payload: { state: 'done', prompt: 'custom task', agentType: 'custom-agent' } - }, - 'conn-1' - ) + ).toBe(true) expect(server.getStatusSnapshot()).toEqual([ expect.objectContaining({ diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 9b1ef9816dd..ad4b5530046 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -19,6 +19,7 @@ import { hasPendingAgentResultText, HOOK_REQUEST_SLOWLORIS_MS, isNewTurnEvent, + markClaudeLeadTurnInterrupted, markCodexLeadTurnInterrupted, MAX_PANE_KEY_LEN, movePaneCacheState, @@ -85,10 +86,6 @@ import { isAgentInterruptInputIntent, type AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent' -import { - reconcileAgentInterruptHint, - type AgentInterruptHint -} from '../../shared/agent-interrupt-hint' import { isAskUserQuestionTool, type AgentQuestionAnsweredInferenceRequest @@ -741,9 +738,6 @@ export class AgentHookServer { private codexSubagentPollTimers = new Map>() private promptSentDedupeByPaneKey = new Map() private activeHookTurnCompletedAtByPaneKey = new Map() - // Why: inference is a hint, not a terminal rewrite. Bounded per pane; cleared on - // status delete, live work, expiry, or a confirming lead `done`. - private interruptHintsByPaneKey = new Map() private promptSentHashSalt = randomBytes(16).toString('hex') private closedAgentStatusTabIds = new Set() private closedAgentStatusPaneKeys = new Set() @@ -946,6 +940,10 @@ export class AgentHookServer { if (dismissesClaudeQuestion) { return this.inferQuestionAnswered(request) } + // Why: Claude owns Escape routing and its Stop hook reports whether the turn was interrupted. + if (agentType === 'claude' && request.intent === 'plain-escape') { + return false + } // Why: inference is a fallback for a missing final hook; a strict baseline match keeps a delayed timer from clobbering any newer hook. if ( payload.state !== 'working' || @@ -970,15 +968,35 @@ export class AgentHookServer { return false } - // Why: silence is not an interrupt; stamp a later lead done only for agents that omit is_interrupt. - this.interruptHintsByPaneKey.set(existing.paneKey, { + // Why: keep the Claude lead-turn record in sync, or a later child event re-emits the stale 'working' state and resurrects the cancelled pane. + if (agentType === 'claude') { + markClaudeLeadTurnInterrupted(this.state, existing.paneKey) + } + if (agentType === 'codex') { + markCodexLeadTurnInterrupted(this.state, existing.paneKey) + } + const inferred = this.applyNormalizedStatus({ paneKey: existing.paneKey, - prompt: payload.prompt, - stateStartedAt: existing.stateStartedAt, - agentType, - recordedAt: Date.now() + tabId: existing.tabId, + worktreeId: existing.worktreeId, + connectionId: existing.connectionId, + providerSession: existing.providerSession, + payload: { + state: 'done', + prompt: payload.prompt, + agentType, + ...(payload.model ? { model: payload.model } : {}), + interrupted: true, + // Why: idle children are display state; dropping them on an inferred interrupt blanks rows a later hook would restore. + ...(payload.subagents ? { subagents: payload.subagents } : {}) + } }) - return false + console.debug('[agent-hooks] inferred interrupted agent status', { + paneKey: inferred.paneKey, + agentType, + intent: request.intent + }) + return true } /** Guarded fallback for the hook Claude omits after answering or dismissing AskUserQuestion. */ @@ -1299,37 +1317,6 @@ export class AgentHookServer { }) } - // Why: a later lead done may confirm the hint; live working expires it instead of painting red. - private applyInterruptHint( - previous: EnrichedAgentHookEventPayload | undefined, - next: AgentHookEventPayload - ): AgentHookEventPayload { - const reconciled = reconcileAgentInterruptHint({ - hint: this.interruptHintsByPaneKey.get(next.paneKey), - now: Date.now(), - incoming: next, - previousStateStartedAt: previous?.stateStartedAt - }) - if (reconciled.hint) { - this.interruptHintsByPaneKey.set(next.paneKey, reconciled.hint) - } else { - this.interruptHintsByPaneKey.delete(next.paneKey) - } - if (!reconciled.stampInterrupted || next.payload.interrupted === true) { - return next - } - if (next.payload.agentType === 'codex') { - markCodexLeadTurnInterrupted(this.state, next.paneKey) - } - return { - ...next, - payload: { - ...next.payload, - interrupted: true - } - } - } - private applyNormalizedStatus( payload: AgentHookEventPayload, onAccepted?: () => void, @@ -1397,8 +1384,7 @@ export class AgentHookServer { ? previousCodexRoot?.payload.model : undefined // Why: an SSH relay restart forgets root-only fields; child hooks must not erase durable resume/model identity. - const rootContextPreservingPayload = this.applyInterruptHint( - previous, + const rootContextPreservingPayload = preservedProviderSession || preservedRootModel ? { ...stateReconciledPayload, @@ -1408,7 +1394,6 @@ export class AgentHookServer { : stateReconciledPayload.payload } : stateReconciledPayload - ) const boundaryReconciledPrevious = invalidateClaudeChildOnlyBoundary( previous, rootContextPreservingPayload @@ -1885,7 +1870,6 @@ export class AgentHookServer { this.clearCodexSubagentPoll(key) clearPaneCacheState(this.state, key) this.activeHookTurnCompletedAtByPaneKey.delete(key) - this.interruptHintsByPaneKey.delete(key) this.runtimeObservedStatusPaneKeys.delete(key) this.currentAuthorityObservations.delete(key) this.promptSentDedupeByPaneKey.delete(key) @@ -2675,7 +2659,6 @@ export class AgentHookServer { } this.state.lastStatusByPaneKey.delete(resolvedPaneKey) this.activeHookTurnCompletedAtByPaneKey.delete(resolvedPaneKey) - this.interruptHintsByPaneKey.delete(resolvedPaneKey) if (!options?.preserveAuthority) { this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey) this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) @@ -3251,10 +3234,6 @@ export class AgentHookServer { _resetConnectionTimestampWatermarksForTests(): void { this.connectionTimestampWatermarkById.clear() } - - _resetInterruptHintsForTests(): void { - this.interruptHintsByPaneKey.clear() - } } export const agentHookServer = new AgentHookServer() @@ -3273,6 +3252,5 @@ export const _internals = { clearAllListenerCaches(agentHookServer._getStateForTests()) agentHookServer._resetPromptSentDedupeForTests() agentHookServer._resetConnectionTimestampWatermarksForTests() - agentHookServer._resetInterruptHintsForTests() } } diff --git a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts index dbc2f1a4024..2b8fc30c817 100644 --- a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts +++ b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts @@ -504,7 +504,7 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { baselineAgentType: 'opencode', intent: 'ctrl-c' }) - ).toBe(false) + ).toBe(true) relay.notifyAgentHook( makeEnvelope({ @@ -670,24 +670,7 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { baselineAgentType: 'opencode', intent: 'ctrl-c' }) - ).toBe(false) - - relay.notifyAgentHook( - makeEnvelope({ - hasExplicitPrompt: true, - payload: { - state: 'done', - prompt: 'replayed prompt', - agentType: 'opencode' - } - }) - ) - await vi.waitFor(() => - expect(agentHookServer.getStatusSnapshot()[0]).toMatchObject({ - state: 'done', - interrupted: true - }) - ) + ).toBe(true) relay.notifyAgentHook( makeEnvelope({ diff --git a/src/shared/agent-interrupt-hint.test.ts b/src/shared/agent-interrupt-hint.test.ts deleted file mode 100644 index 03994d2f3af..00000000000 --- a/src/shared/agent-interrupt-hint.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - AGENT_INTERRUPT_HINT_TTL_MS, - reconcileAgentInterruptHint, - type AgentInterruptHint -} from './agent-interrupt-hint' - -const HINT: AgentInterruptHint = { - paneKey: 'tab-1:11111111-1111-4111-8111-111111111111', - prompt: 'long task', - stateStartedAt: 900, - agentType: 'opencode', - recordedAt: 1_500 -} - -describe('reconcileAgentInterruptHint', () => { - it('expires the hint when the same turn is still working', () => { - expect( - reconcileAgentInterruptHint({ - hint: HINT, - now: 1_600, - previousStateStartedAt: 900, - incoming: { - paneKey: HINT.paneKey, - hookEventName: 'PostToolUse', - payload: { state: 'working', prompt: 'long task', agentType: 'opencode' } - } - }) - ).toEqual({ hint: undefined, stampInterrupted: false }) - }) - - it('stamps a lead done for agents that omit is_interrupt', () => { - expect( - reconcileAgentInterruptHint({ - hint: HINT, - now: 1_501, - previousStateStartedAt: 900, - incoming: { - paneKey: HINT.paneKey, - payload: { state: 'done', prompt: 'long task', agentType: 'opencode' } - } - }) - ).toEqual({ hint: undefined, stampInterrupted: true }) - }) - - it('does not override Claude Stop — is_interrupt absence is the hook contract', () => { - expect( - reconcileAgentInterruptHint({ - hint: { ...HINT, agentType: 'claude' }, - now: 1_501, - previousStateStartedAt: 900, - incoming: { - paneKey: HINT.paneKey, - hookEventName: 'Stop', - payload: { state: 'done', prompt: 'long task', agentType: 'claude' } - } - }) - ).toEqual({ hint: undefined, stampInterrupted: false }) - }) - - it('does not stamp a child stop onto the lead row', () => { - expect( - reconcileAgentInterruptHint({ - hint: HINT, - now: 1_501, - previousStateStartedAt: 900, - incoming: { - paneKey: HINT.paneKey, - hookEventName: 'SubagentStop', - toolAgentId: 'child-1', - payload: { state: 'done', prompt: 'long task', agentType: 'opencode' } - } - }) - ).toEqual({ hint: HINT, stampInterrupted: false }) - }) - - it('lets a delayed completion expire instead of inheriting the keystroke', () => { - expect( - reconcileAgentInterruptHint({ - hint: HINT, - now: HINT.recordedAt + AGENT_INTERRUPT_HINT_TTL_MS + 1, - previousStateStartedAt: 900, - incoming: { - paneKey: HINT.paneKey, - payload: { state: 'done', prompt: 'long task', agentType: 'opencode' } - } - }) - ).toEqual({ hint: undefined, stampInterrupted: false }) - }) -}) diff --git a/src/shared/agent-interrupt-hint.ts b/src/shared/agent-interrupt-hint.ts deleted file mode 100644 index 2371cbcc6bb..00000000000 --- a/src/shared/agent-interrupt-hint.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { AGENT_INTERRUPT_SETTLE_MS } from './agent-interrupt-intent' -import type { AgentStatusState, AgentType } from './agent-status-types' - -/** Same 500ms budget as settle — silence expires the hint; it never synthesizes `done`. */ -export const AGENT_INTERRUPT_HINT_TTL_MS = AGENT_INTERRUPT_SETTLE_MS - -const CHILD_LIFECYCLE_HOOK_EVENTS = new Set(['SubagentStart', 'SubagentStop', 'TeammateIdle']) - -export type AgentInterruptHint = { - paneKey: string - prompt: string - stateStartedAt: number - agentType: AgentType | undefined - recordedAt: number -} - -function equivalentHintAgentType( - actual: AgentType | undefined, - baseline: AgentType | undefined -): boolean { - const normalizedActual = actual === 'unknown' ? undefined : actual - const normalizedBaseline = baseline === 'unknown' ? undefined : baseline - return normalizedActual === normalizedBaseline -} - -/** Claude/Kimi Stop already carry `is_interrupt`; absence of the flag is authoritative. */ -export function isHookAuthoritativeInterruptAgent(agentType: AgentType | undefined): boolean { - return agentType === 'claude' || agentType === 'openclaude' || agentType === 'kimi' -} - -export type ReconcileAgentInterruptHintResult = { - hint: AgentInterruptHint | undefined - stampInterrupted: boolean -} - -export function reconcileAgentInterruptHint({ - hint, - now, - incoming, - previousStateStartedAt -}: { - hint: AgentInterruptHint | undefined - now: number - incoming: { - paneKey: string - hookEventName?: string - toolAgentId?: string - payload: { - state: AgentStatusState - prompt: string - agentType?: AgentType - interrupted?: boolean - } - } - previousStateStartedAt?: number -}): ReconcileAgentInterruptHintResult { - if (!hint || hint.paneKey !== incoming.paneKey) { - return { hint, stampInterrupted: false } - } - - const sameTurn = - hint.prompt === incoming.payload.prompt && - equivalentHintAgentType(hint.agentType, incoming.payload.agentType) && - (previousStateStartedAt === undefined || hint.stateStartedAt === previousStateStartedAt) - - if (!sameTurn) { - return { hint: undefined, stampInterrupted: false } - } - - const live = now - hint.recordedAt <= AGENT_INTERRUPT_HINT_TTL_MS - if ( - incoming.payload.state === 'working' || - incoming.payload.state === 'waiting' || - incoming.payload.state === 'blocked' - ) { - return { hint: undefined, stampInterrupted: false } - } - if (incoming.payload.state !== 'done') { - return { hint: live ? hint : undefined, stampInterrupted: false } - } - if (!live) { - return { hint: undefined, stampInterrupted: false } - } - if (isHookAuthoritativeInterruptAgent(incoming.payload.agentType)) { - return { hint: undefined, stampInterrupted: false } - } - if (incoming.toolAgentId || CHILD_LIFECYCLE_HOOK_EVENTS.has(incoming.hookEventName ?? '')) { - return { hint, stampInterrupted: false } - } - return { hint: undefined, stampInterrupted: true } -} diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 5800c4dff3d..6df1c6568f7 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -1,7 +1,6 @@ // ─── Explicit agent status (reported via native agent hooks → IPC) ────────── // Why: status comes from hooks (Claude, Codex, etc.) — never inferred from terminal titles; -// a keystroke may record an interrupt hint that stamps a later authoritative `done`, but it -// never retires a live `working` row. +// a narrow interrupt fallback synthesizes a final `done` when an agent misses its cancellation hook. import type { AgentProviderSessionMetadata } from './agent-session-resume' import type { WithAgentStatusObservation } from './agent-status-observation'