diff --git a/src/main/agent-hooks/server-escape-navigation-inference.test.ts b/src/main/agent-hooks/server-escape-navigation-inference.test.ts index 77359caf380..0bf7cdeb57b 100644 --- a/src/main/agent-hooks/server-escape-navigation-inference.test.ts +++ b/src/main/agent-hooks/server-escape-navigation-inference.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentInterruptInputIntent } from '../../shared/agent-interrupt-intent' import type { EnrichedAgentHookEventPayload } from './server/server-types' import { AgentHookServer, _internals } from './server' -import { PANE } from './server.test-fixtures' +import { buildBody, PANE, postHookEvent } from './server.test-fixtures' const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ getCohortAtEmitMock: vi.fn(), @@ -156,6 +156,25 @@ describe('navigation Escape during an open tool call', () => { }) }) + it.each([ + ['pi', 'tool_call'], + ['prime-agent', 'tool_execution_start'] + ])('leaves %s work running when Escape closes an overlay mid-%s', (agentType, hookEventName) => { + const server = new AgentHookServer() + ingest(server, { + source: agentType, + hookEventName, + state: 'working', + prompt: 'refactor the parser', + agentType, + toolName: 'shell' + }) + + vi.setSystemTime(1_200) + expect(pressInterruptKey(server, 'plain-escape')).toBe(false) + expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ state: 'working' }) + }) + it('still infers a Claude Escape once the tool call has closed', () => { const server = new AgentHookServer() ingest(server, { @@ -305,3 +324,47 @@ describe('navigation Escape during an open tool call', () => { }) }) }) + +/** The guard has to hold on the path a real agent CLI uses, not only on ingestRemote: the + * loopback listener is what stamps `hookEventName` for a locally launched agent. */ +describe('navigation Escape over the loopback hook listener', () => { + it.each([ + ['PreToolUse', false, 'working'], + ['PostToolUse', true, 'done'] + ])( + 'a Claude row last seen on %s answers %s to a single Escape', + async (hookEventName, expectedInference, expectedState) => { + vi.useRealTimers() + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + await postHookEvent( + server, + buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'migrate the schema' }) + ) + await postHookEvent( + server, + buildBody({ + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'pnpm migrate' } + }) + ) + if (hookEventName === 'PostToolUse') { + await postHookEvent( + server, + buildBody({ hook_event_name: 'PostToolUse', tool_name: 'Bash' }) + ) + } + + expect(pressInterruptKey(server, 'plain-escape')).toBe(expectedInference) + expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ + state: expectedState, + ...(expectedInference ? { interrupted: true } : {}) + }) + } finally { + server.stop() + } + } + ) +}) diff --git a/src/main/agent-hooks/server/server-constants.ts b/src/main/agent-hooks/server/server-constants.ts index 7fa7901ae73..e7ed7d53972 100644 --- a/src/main/agent-hooks/server/server-constants.ts +++ b/src/main/agent-hooks/server/server-constants.ts @@ -17,6 +17,18 @@ export const TOOL_PROGRESS_HOOK_EVENTS = new Set([ 'PostToolUse', 'PostToolUseFailure' ]) + +// Why: the events that OPEN a provider tool call, for the agent types whose TUI also binds a bare +// Escape to navigation. A row still sitting on one of these has not seen its closing event +// (PostToolUse / tool_execution_end), so hook silence there is the tool running, not an interrupt. +// Closed by construction: Claude ships PreToolUse as its only tool-opening hook, and Orca itself +// generates the pi/omp/prime-agent extension (main/pi/agent-status-handler-source.ts), so neither +// provider can introduce an opener without a change here. +export const OPEN_TOOL_CALL_HOOK_EVENTS: ReadonlySet = new Set([ + 'PreToolUse', + 'tool_call', + 'tool_execution_start' +]) export const AGENT_PROMPT_SENT_AGENT_KINDS = new Set(AGENT_KIND_VALUES) // Why: bound file growth from PTYs that never re-attach; 7 days is the "still relevant?" horizon beyond which entries shouldn't resurrect on hydrate. diff --git a/src/main/agent-hooks/server/server-status-inference.ts b/src/main/agent-hooks/server/server-status-inference.ts index 2e9aa73dd8e..fa6815da310 100644 --- a/src/main/agent-hooks/server/server-status-inference.ts +++ b/src/main/agent-hooks/server/server-status-inference.ts @@ -14,10 +14,12 @@ import { import { AGENT_STATUS_STALE_AFTER_MS, type AgentType } from '../../../shared/agent-status-types' import type { EnrichedAgentHookEventPayload } from './server-types' import { equivalentInterruptAgentType, isValidPaneKey } from './server-status-identity' +import { OPEN_TOOL_CALL_HOOK_EVENTS } from './server-constants' import { AgentHookServerRowOwnership } from './server-row-ownership' // Why: these TUIs also close an overlay on a bare Escape (Claude's /btw composer, OMP/Pi's // focused-child and settings views), so one press cannot mean "interrupt" on its own (#13547, #9208). +// Sibling policy for agents whose first Escape is a cancel lives in the double-Escape gate below. const ESCAPE_ALSO_NAVIGATES_AGENT_TYPES: ReadonlySet = new Set([ 'claude', 'omp', @@ -25,15 +27,6 @@ const ESCAPE_ALSO_NAVIGATES_AGENT_TYPES: ReadonlySet = new Set([ 'prime-agent' ]) -// Why: hook events that OPEN a provider tool call. Their closing event (PostToolUse / -// tool_execution_end) replaces the row, so a row still sitting on one of these means the tool -// is running now. -const OPEN_TOOL_CALL_HOOK_EVENTS: ReadonlySet = new Set([ - 'PreToolUse', - 'tool_call', - 'tool_execution_start' -]) - /** Hook silence during an open tool call is evidence the tool is still running, not that the * turn was interrupted — so an ambiguous single Escape may not retire the row. */ function isNavigationEscapeDuringOpenToolCall(