Files
orca/src/main/agent-hooks/server-codex-interrupt-hook.test.ts
T
Jinwoo Hong 477e699922 fix(codex): install Codex's Interrupt hook so an Esc-cancelled turn settles (#24332)
* fix(codex): install Codex's Interrupt hook so an Esc-cancelled turn settles

Codex 0.150+ fires an Interrupt hook when the user presses Esc on an
approval prompt or mid-tool, and nothing else. Orca did not install it, so
the pane stayed blocked/working until the next prompt.

- Add Interrupt to the managed Codex events and label maps, written with
  Codex's 3s cap (a larger value triggers a startup clamp warning).
- Hash the timeout Codex hashes (Interrupt is clamped to [1,3], default 1)
  so self-computed trust matches Codex; pinned against a real 0.159.3 hash.
- Map a root Interrupt to the existing cancelled-turn record
  (markCodexLeadTurnInterrupted), keeping child work in the fold; a
  child-scoped Interrupt is ignored. Relayed rows take the same path.

* refactor(codex): let the hook builder own Codex's per-event timeout

The managed hook's timeout is now Codex's own normalization of the shared
budget, and every installer derives its trust entry from the hook it wrote,
so no installer repeats the Interrupt special case.

Claude-Session: codex-interrupt-hook review

* refactor(codex): route Interrupt through the Stop lead update with an outcome

Interrupt now writes the lead record through the same setCodexMainAgentTurnState
call as Stop, so markCodexLeadTurnInterrupted keeps its original signature.
Drops the child-scoped Interrupt guard: Codex never runs Interrupt hooks for
subagents and its input schema has no agent_id.

Claude-Session: codex-interrupt-hook review

* fix(codex): ignore an Interrupt from an earlier turn once the next turn has started

A replayed or late Interrupt carries the old turn's turn_id; matching it against the turn_id from the
running turn's UserPromptSubmit keeps it from cancelling the new turn. Missing ids still cancel.

* revert(codex): drop the Interrupt turn-id guard; delivery is already ordered

Hook events reach Orca in order: Codex waits for the Interrupt hook before the next turn, and the restart spool is one append-only file per pane, replayed in order before live events. The guard protected an unreachable case and could drop a real cancel if the ids ever differed.
2026-10-01 15:56:01 -04:00

91 lines
3.2 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AgentHookServer, _internals } from './server'
import { buildBody, 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()
getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 })
})
afterEach(() => {
vi.restoreAllMocks()
})
// Why: the order (and the late PostToolUse) is what codex-cli 0.159.3 posted live for Esc on an
// approval prompt, then Esc while `sleep 40` ran; the shell finished 24s after the Interrupt.
const TURN_1 = { session_id: 'session-1', turn_id: 'turn-1', model: 'gpt-5.5' }
const TURN_2 = { session_id: 'session-1', turn_id: 'turn-2', model: 'gpt-5.5' }
const BASH = { tool_name: 'Bash', tool_input: { command: 'sleep 40' }, tool_use_id: 'exec-2' }
describe("Codex's Interrupt hook through the status store", () => {
let server: AgentHookServer
let post: (payload: Record<string, unknown>) => Promise<void>
beforeEach(async () => {
server = new AgentHookServer()
await server.start({ env: 'production' })
const env = server.buildPtyEnv()
post = async (payload) => {
const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/codex`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload))
})
expect(response.status).toBe(204)
}
})
afterEach(() => {
server.stop()
})
function row(): ReturnType<AgentHookServer['getStatusSnapshot']>[number] | undefined {
return server.getStatusSnapshot().find((entry) => entry.paneKey === PANE)
}
it('settles a declined approval and a cancelled tool, and holds the late tool result', async () => {
await post({ ...TURN_1, hook_event_name: 'UserPromptSubmit', prompt: 'touch hello.txt' })
await post({ ...TURN_1, ...BASH, hook_event_name: 'PreToolUse' })
await post({ ...TURN_1, ...BASH, hook_event_name: 'PermissionRequest' })
expect(row()?.state).toBe('waiting')
await post({ ...TURN_1, hook_event_name: 'Interrupt', permission_mode: 'default' })
expect(row()).toMatchObject({
state: 'done',
interrupted: true,
mainAgent: { state: 'done', outcome: 'cancellation' }
})
await post({ ...TURN_2, hook_event_name: 'UserPromptSubmit', prompt: 'sleep 40' })
await post({ ...TURN_2, ...BASH, hook_event_name: 'PreToolUse' })
expect(row()?.state).toBe('working')
await post({ ...TURN_2, hook_event_name: 'Interrupt', permission_mode: 'default' })
expect(row()).toMatchObject({ state: 'done', interrupted: true })
const lateBy = Date.now() + 24_000
vi.spyOn(Date, 'now').mockReturnValue(lateBy)
await post({ ...TURN_2, ...BASH, hook_event_name: 'PostToolUse', tool_response: '' })
expect(row()).toMatchObject({
state: 'done',
mainAgent: { state: 'done', outcome: 'cancellation' }
})
})
})