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 new file mode 100644 index 00000000000..aa343969cf8 --- /dev/null +++ b/src/main/agent-hooks/server-interrupt-inference-escape-navigation.test.ts @@ -0,0 +1,297 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AgentHookServer, _internals } from './server' +import { PANE } from './server.test-fixtures' + +const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ + getCohortAtEmitMock: vi.fn(), + trackMock: vi.fn() +})) + +vi.mock('../telemetry/client', () => ({ + track: trackMock +})) + +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: getCohortAtEmitMock +})) + +beforeEach(() => { + _internals.resetCachesForTests() + trackMock.mockReset() + getCohortAtEmitMock.mockReset() + getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +function ingestWorking( + server: AgentHookServer, + payload: { + prompt: string + agentType: 'claude' | 'codex' | 'opencode' + hookEventName?: string + } +): ReturnType[0] { + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + hookEventName: payload.hookEventName ?? 'UserPromptSubmit', + payload: { + state: 'working', + prompt: payload.prompt, + agentType: payload.agentType + } + }, + 'conn-1' + ) + return server.getStatusSnapshot()[0]! +} + +function inferEscape( + server: AgentHookServer, + baseline: { + receivedAt: number + stateStartedAt: number + prompt: string + agentType: string | undefined + } +) { + return server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: baseline.prompt, + baselineAgentType: baseline.agentType, + intent: 'plain-escape' + }) +} + +describe('interrupt inference yields to a live working hook row', () => { + it('does not retire a working Claude turn when Escape is consumed as TUI navigation', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + const baseline = ingestWorking(server, { prompt: 'long tool call', agentType: 'claude' }) + + vi.setSystemTime(1_500) + expect(inferEscape(server, baseline)).toBe(false) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + prompt: 'long tool call', + agentType: 'claude', + interrupted: undefined + }) + + vi.setSystemTime(12_000) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hookEventName: 'Stop', + payload: { state: 'done', prompt: 'long tool call', agentType: 'claude' } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + prompt: 'long tool call', + agentType: 'claude', + interrupted: undefined + }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps a working row live when a later same-turn tool hook arrives after Escape', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + const baseline = ingestWorking(server, { prompt: 'still running', agentType: 'claude' }) + + vi.setSystemTime(1_500) + expect(inferEscape(server, baseline)).toBe(false) + + vi.setSystemTime(2_000) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + hookEventName: 'PostToolUse', + payload: { + state: 'working', + prompt: 'still running', + agentType: 'claude', + toolName: 'Bash', + toolInput: 'sleep 90' + } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + prompt: 'still running', + agentType: 'claude', + toolName: 'Bash', + interrupted: undefined + }) + } finally { + vi.useRealTimers() + } + }) + + it('reports a real Claude interrupt from the Stop hook, not from Escape inference', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + const baseline = ingestWorking(server, { prompt: 'cancel me', agentType: 'claude' }) + + vi.setSystemTime(1_200) + expect(inferEscape(server, baseline)).toBe(false) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) + + vi.setSystemTime(1_300) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hookEventName: 'Stop', + payload: { + state: 'done', + prompt: 'cancel me', + agentType: 'claude', + interrupted: true + } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + prompt: 'cancel me', + agentType: 'claude', + interrupted: true + }) + } finally { + vi.useRealTimers() + } + }) + + it('does not latch working after a hook-confirmed interrupt, and late tool progress cannot resurrect it', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + const baseline = ingestWorking(server, { prompt: 'stop the turn', agentType: 'claude' }) + + vi.setSystemTime(1_200) + expect(inferEscape(server, baseline)).toBe(false) + + vi.setSystemTime(1_300) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hookEventName: 'Stop', + payload: { + state: 'done', + prompt: 'stop the turn', + agentType: 'claude', + interrupted: true + } + }, + 'conn-1' + ) + + vi.setSystemTime(2_000) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + hookEventName: 'PostToolUse', + payload: { + state: 'working', + prompt: 'stop the turn', + agentType: 'claude', + toolName: 'Bash', + toolInput: 'sleep 90' + } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + prompt: 'stop the turn', + agentType: 'claude', + interrupted: true, + receivedAt: 1_300 + }) + } finally { + 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 8d0d6a3975c..0b40c552e7d 100644 --- a/src/main/agent-hooks/server-interrupt-inference-guards.test.ts +++ b/src/main/agent-hooks/server-interrupt-inference-guards.test.ts @@ -62,7 +62,26 @@ describe('AgentHookServer listener replay', () => { intent: 'plain-escape' }) - expect(applied).toBe(true) + 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(server.getStatusSnapshot()).toEqual([ expect.objectContaining({ paneKey: PANE, @@ -71,8 +90,8 @@ describe('AgentHookServer listener replay', () => { agentType: 'codex', providerSession: { key: 'session_id', id: 'codex-interrupt-session-1' }, interrupted: true, - receivedAt: 1_500, - stateStartedAt: 1_500 + receivedAt: 1_501, + stateStartedAt: 1_501 }) ]) expect(listener).toHaveBeenLastCalledWith( @@ -198,7 +217,8 @@ describe('AgentHookServer listener replay', () => { baselineAgentType: 'claude', intent: 'plain-escape' }) - ).toBe(true) + ).toBe(false) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) } finally { vi.useRealTimers() } @@ -328,7 +348,8 @@ describe('AgentHookServer listener replay', () => { baselineAgentType: 'claude', intent: 'ctrl-c' }) - ).toBe(true) + ).toBe(false) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) } finally { vi.useRealTimers() } @@ -402,7 +423,27 @@ describe('AgentHookServer listener replay', () => { intent: 'ctrl-c' }) - expect(applied).toBe(true) + 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(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', interrupted: true, @@ -442,7 +483,8 @@ describe('AgentHookServer listener replay', () => { intent: 'plain-escape', inputCount: 2 }) - ).toBe(true) + ).toBe(false) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) vi.setSystemTime(1_501) server.ingestRemote( @@ -462,8 +504,8 @@ describe('AgentHookServer listener replay', () => { prompt: 'long task', agentType: 'opencode', interrupted: true, - receivedAt: 1_500, - stateStartedAt: 1_500 + receivedAt: 1_501, + stateStartedAt: 1_501 }) ]) expect(listener).toHaveBeenLastCalledWith( @@ -595,7 +637,20 @@ describe('AgentHookServer listener replay', () => { inputCount: 2 }) - expect(applied).toBe(true) + 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(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 d8fd8ecffbf..787dad2083c 100644 --- a/src/main/agent-hooks/server-interrupt-inference-resurrection.test.ts +++ b/src/main/agent-hooks/server-interrupt-inference-resurrection.test.ts @@ -53,7 +53,18 @@ describe('AgentHookServer listener replay', () => { baselineAgentType: 'pi', intent: 'ctrl-c' }) - ).toBe(true) + ).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' + ) vi.setSystemTime(6_000) server.ingestRemote( @@ -78,8 +89,8 @@ describe('AgentHookServer listener replay', () => { prompt: 'long task', agentType: 'pi', interrupted: true, - receivedAt: 1_500, - stateStartedAt: 1_500 + receivedAt: 1_501, + stateStartedAt: 1_501 }) ]) } finally { @@ -119,7 +130,24 @@ describe('AgentHookServer listener replay', () => { baselineAgentType: 'claude', intent: 'ctrl-c' }) - ).toBe(true) + ).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' + ) vi.setSystemTime(2_000) server.ingestRemote( @@ -146,8 +174,8 @@ describe('AgentHookServer listener replay', () => { prompt: 'Do I have gpu acceleration on on my terminal?', agentType: 'claude', interrupted: true, - receivedAt: 1_500, - stateStartedAt: 1_500 + receivedAt: 1_501, + stateStartedAt: 1_501 }) ]) } finally { @@ -187,7 +215,22 @@ describe('AgentHookServer listener replay', () => { baselineAgentType: 'codex', intent: 'plain-escape' }) - ).toBe(true) + ).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' + ) vi.setSystemTime(6_000) server.ingestRemote( @@ -214,8 +257,8 @@ describe('AgentHookServer listener replay', () => { prompt: 'Run sleep 30, then reply done.', agentType: 'codex', interrupted: true, - receivedAt: 1_500, - stateStartedAt: 1_500 + receivedAt: 1_501, + stateStartedAt: 1_501 }) ]) } finally { @@ -249,7 +292,7 @@ describe('AgentHookServer listener replay', () => { baselineAgentType: 'pi', intent: 'ctrl-c' }) - ).toBe(true) + ).toBe(false) vi.setSystemTime(2_000) server.ingestRemote( @@ -268,7 +311,7 @@ describe('AgentHookServer listener replay', () => { prompt: 'second task', agentType: 'pi', receivedAt: 2_000, - stateStartedAt: 2_000 + stateStartedAt: 1_000 }) ]) } finally { @@ -304,7 +347,7 @@ describe('AgentHookServer listener replay', () => { baselineAgentType: 'claude', intent: 'ctrl-c' }) - ).toBe(true) + ).toBe(false) vi.setSystemTime(2_000) server.ingestRemote( @@ -326,7 +369,7 @@ describe('AgentHookServer listener replay', () => { agentType: 'claude', interrupted: undefined, receivedAt: 2_000, - stateStartedAt: 2_000 + stateStartedAt: 1_000 }) ]) } finally { @@ -361,7 +404,7 @@ describe('AgentHookServer listener replay', () => { baselineAgentType: 'pi', intent: 'ctrl-c' }) - ).toBe(true) + ).toBe(false) vi.setSystemTime(2_000) server.ingestRemote( @@ -381,7 +424,7 @@ describe('AgentHookServer listener replay', () => { prompt: 'retryable task', agentType: 'pi', receivedAt: 2_000, - stateStartedAt: 2_000 + stateStartedAt: 1_000 }) ]) } finally { @@ -417,7 +460,24 @@ describe('AgentHookServer listener replay', () => { baselineAgentType: 'claude', intent: 'ctrl-c' }) - ).toBe(true) + ).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' + ) vi.setSystemTime(16_501) server.ingestRemote( @@ -444,8 +504,8 @@ describe('AgentHookServer listener replay', () => { prompt: 'repeat task', agentType: 'claude', interrupted: true, - receivedAt: 1_500, - stateStartedAt: 1_500 + receivedAt: 1_501, + stateStartedAt: 1_501 }) ]) } finally { @@ -479,7 +539,7 @@ describe('AgentHookServer listener replay', () => { baselineAgentType: 'pi', intent: 'ctrl-c' }) - ).toBe(true) + ).toBe(false) vi.setSystemTime(16_501) server.ingestRemote( @@ -499,7 +559,7 @@ describe('AgentHookServer listener replay', () => { agentType: 'pi', interrupted: undefined, receivedAt: 16_501, - stateStartedAt: 16_501 + stateStartedAt: 1_000 }) ]) } finally { @@ -533,7 +593,7 @@ describe('AgentHookServer listener replay', () => { baselineAgentType: 'pi', intent: 'ctrl-c' }) - ).toBe(true) + ).toBe(false) vi.setSystemTime(16_501) server.ingestRemote( @@ -561,7 +621,7 @@ describe('AgentHookServer listener replay', () => { toolName: 'bash', toolInput: '/bin/sleep 90', receivedAt: 16_501, - stateStartedAt: 16_501 + stateStartedAt: 1_000 }) ]) } 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 971da33b22c..df8a72997ab 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(true) + ).toBe(false) vi.setSystemTime(2_000) server.ingestRemote( @@ -153,7 +153,7 @@ describe('AgentHookServer listener replay', () => { agentType: 'opencode', lastAssistantMessage: 'partial answer', receivedAt: 2_000, - stateStartedAt: 2_000 + stateStartedAt: 1_000 }) ]) } finally { @@ -193,7 +193,23 @@ describe('AgentHookServer listener replay', () => { baselineAgentType: 'opencode', intent: 'ctrl-c' }) - ).toBe(true) + ).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' + ) vi.setSystemTime(20_000) server.ingestRemote( @@ -219,8 +235,8 @@ describe('AgentHookServer listener replay', () => { prompt: 'retryable task', agentType: 'opencode', interrupted: true, - receivedAt: 1_500, - stateStartedAt: 1_500 + receivedAt: 1_501, + stateStartedAt: 1_501 }) ]) } finally { @@ -254,7 +270,19 @@ describe('AgentHookServer listener replay', () => { baselineAgentType: 'unknown', intent: 'ctrl-c' }) - ).toBe(true) + ).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' + ) expect(server.getStatusSnapshot()).toEqual([ expect.objectContaining({ @@ -346,7 +374,26 @@ describe('AgentHookServer listener replay', () => { baselineAgentType: 'custom-agent', intent: 'ctrl-c' }) - ).toBe(true) + ).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' + ) expect(server.getStatusSnapshot()).toEqual([ expect.objectContaining({ diff --git a/src/main/agent-hooks/server-relay-listener-replay.test.ts b/src/main/agent-hooks/server-relay-listener-replay.test.ts index c7795fb8069..faa0b3ec891 100644 --- a/src/main/agent-hooks/server-relay-listener-replay.test.ts +++ b/src/main/agent-hooks/server-relay-listener-replay.test.ts @@ -203,7 +203,8 @@ describe('AgentHookServer listener replay', () => { baselineAgentType: 'claude', intent: 'plain-escape' }) - ).toBe(true) + ).toBe(false) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) } finally { vi.useRealTimers() } diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 58ad669784b..9b1ef9816dd 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -19,7 +19,6 @@ import { hasPendingAgentResultText, HOOK_REQUEST_SLOWLORIS_MS, isNewTurnEvent, - markClaudeLeadTurnInterrupted, markCodexLeadTurnInterrupted, MAX_PANE_KEY_LEN, movePaneCacheState, @@ -86,6 +85,10 @@ import { isAgentInterruptInputIntent, type AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent' +import { + reconcileAgentInterruptHint, + type AgentInterruptHint +} from '../../shared/agent-interrupt-hint' import { isAskUserQuestionTool, type AgentQuestionAnsweredInferenceRequest @@ -738,6 +741,9 @@ 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() @@ -964,35 +970,15 @@ export class AgentHookServer { return false } - // 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({ + // Why: silence is not an interrupt; stamp a later lead done only for agents that omit is_interrupt. + this.interruptHintsByPaneKey.set(existing.paneKey, { paneKey: existing.paneKey, - 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 } : {}) - } - }) - console.debug('[agent-hooks] inferred interrupted agent status', { - paneKey: inferred.paneKey, + prompt: payload.prompt, + stateStartedAt: existing.stateStartedAt, agentType, - intent: request.intent + recordedAt: Date.now() }) - return true + return false } /** Guarded fallback for the hook Claude omits after answering or dismissing AskUserQuestion. */ @@ -1313,6 +1299,37 @@ 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, @@ -1380,7 +1397,8 @@ 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 = + const rootContextPreservingPayload = this.applyInterruptHint( + previous, preservedProviderSession || preservedRootModel ? { ...stateReconciledPayload, @@ -1390,6 +1408,7 @@ export class AgentHookServer { : stateReconciledPayload.payload } : stateReconciledPayload + ) const boundaryReconciledPrevious = invalidateClaudeChildOnlyBoundary( previous, rootContextPreservingPayload @@ -1866,6 +1885,7 @@ 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) @@ -2655,6 +2675,7 @@ 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) @@ -3230,6 +3251,10 @@ export class AgentHookServer { _resetConnectionTimestampWatermarksForTests(): void { this.connectionTimestampWatermarkById.clear() } + + _resetInterruptHintsForTests(): void { + this.interruptHintsByPaneKey.clear() + } } export const agentHookServer = new AgentHookServer() @@ -3248,5 +3273,6 @@ 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 2b8fc30c817..dbc2f1a4024 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(true) + ).toBe(false) relay.notifyAgentHook( makeEnvelope({ @@ -670,7 +670,24 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { baselineAgentType: 'opencode', intent: 'ctrl-c' }) - ).toBe(true) + ).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 + }) + ) relay.notifyAgentHook( makeEnvelope({ diff --git a/src/shared/agent-interrupt-hint.test.ts b/src/shared/agent-interrupt-hint.test.ts new file mode 100644 index 00000000000..03994d2f3af --- /dev/null +++ b/src/shared/agent-interrupt-hint.test.ts @@ -0,0 +1,90 @@ +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 new file mode 100644 index 00000000000..2371cbcc6bb --- /dev/null +++ b/src/shared/agent-interrupt-hint.ts @@ -0,0 +1,91 @@ +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 6df1c6568f7..5800c4dff3d 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -1,6 +1,7 @@ // ─── Explicit agent status (reported via native agent hooks → IPC) ────────── // Why: status comes from hooks (Claude, Codex, etc.) — never inferred from terminal titles; -// a narrow interrupt fallback synthesizes a final `done` when an agent misses its cancellation hook. +// a keystroke may record an interrupt hint that stamps a later authoritative `done`, but it +// never retires a live `working` row. import type { AgentProviderSessionMetadata } from './agent-session-resume' import type { WithAgentStatusObservation } from './agent-status-observation'