refactor(agent-hooks): home the open-tool-call event set with its siblings

Sits next to TOOL_PROGRESS_HOOK_EVENTS, the existing hook-event-name set read by a
main-process transition guard, so the concept keeps one home. Adds pi/prime-agent
coverage and drives the contrast pair over the real loopback hook listener, which is
what stamps hookEventName for a locally launched agent.
This commit is contained in:
Neil
2026-09-12 22:05:34 -07:00
parent 055b20f902
commit 052ebf707e
3 changed files with 78 additions and 10 deletions
@@ -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()
}
}
)
})
@@ -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<string> = new Set([
'PreToolUse',
'tool_call',
'tool_execution_start'
])
export const AGENT_PROMPT_SENT_AGENT_KINDS = new Set<AgentKind>(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.
@@ -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<AgentType> = new Set([
'claude',
'omp',
@@ -25,15 +27,6 @@ const ESCAPE_ALSO_NAVIGATES_AGENT_TYPES: ReadonlySet<AgentType> = 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<string> = 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(