diff --git a/src/main/agent-hooks/server-codex-turn-interruption.test.ts b/src/main/agent-hooks/server-codex-turn-interruption.test.ts index b5ab96d54f2..4214a4146f8 100644 --- a/src/main/agent-hooks/server-codex-turn-interruption.test.ts +++ b/src/main/agent-hooks/server-codex-turn-interruption.test.ts @@ -85,16 +85,22 @@ describe('Codex recorded turn interruption', () => { expect(server.getStatusSnapshot()[0]).toEqual(beforeSide) } const baseline = server.getStatusSnapshot()[0] - expect( - server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: baseline.prompt, - baselineAgentType: 'codex', - intent: 'ctrl-c' - }) - ).toBe(false) + for (const intent of ['ctrl-c', 'plain-escape'] as const) { + for (const inputCount of [1, 2]) { + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: baseline.prompt, + baselineAgentType: 'codex', + intent, + inputCount + }) + ).toBe(false) + expect(server.getStatusSnapshot()[0]).toEqual(baseline) + } + } await new Promise((resolve) => setTimeout(resolve, 600)) expect(server.getStatusSnapshot()[0].state).toBe('working') appendFileSync( 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 ff68f7492e5..fa0fec6e8cd 100644 --- a/src/main/agent-hooks/server-interrupt-inference-guards.test.ts +++ b/src/main/agent-hooks/server-interrupt-inference-guards.test.ts @@ -27,7 +27,7 @@ afterEach(() => { }) describe('AgentHookServer listener replay', () => { - it('keeps Codex lead state terminal after an inferred interrupt', () => { + it('keeps Codex lead state terminal after a confirmed interrupt', () => { vi.useFakeTimers() vi.setSystemTime(1_000) try { @@ -50,19 +50,19 @@ describe('AgentHookServer listener replay', () => { }, 'conn-1' ) - const baseline = server.getStatusSnapshot()[0] - vi.setSystemTime(1_500) - const applied = server.inferInterrupt({ - paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'long task', - baselineAgentType: 'codex', - intent: 'plain-escape' - }) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + providerSession: { key: 'session_id', id: 'codex-interrupt-session-1' }, + hookEventName: 'Interrupt', + payload: { state: 'done', prompt: 'long task', agentType: 'codex', interrupted: true } + }, + 'conn-1' + ) - expect(applied).toBe(true) expect(server.getStatusSnapshot()).toEqual([ expect.objectContaining({ paneKey: PANE, 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 9619ef6de9d..06ca160b6ba 100644 --- a/src/main/agent-hooks/server-interrupt-inference-resurrection.test.ts +++ b/src/main/agent-hooks/server-interrupt-inference-resurrection.test.ts @@ -87,7 +87,7 @@ describe('AgentHookServer listener replay', () => { } }) - it('does not let late Codex tool hooks with explicit prompt resurrect an inferred interrupt', () => { + it('does not let late Codex tool hooks with explicit prompt resurrect a confirmed interrupt', () => { vi.useFakeTimers() vi.setSystemTime(1_000) try { @@ -107,19 +107,22 @@ describe('AgentHookServer listener replay', () => { }, 'conn-1' ) - const baseline = server.getStatusSnapshot()[0] - vi.setSystemTime(1_500) - expect( - server.inferInterrupt({ + server.ingestRemote( + { paneKey: PANE, - baselineUpdatedAt: baseline.receivedAt, - baselineStateStartedAt: baseline.stateStartedAt, - baselinePrompt: 'Run sleep 30, then reply done.', - baselineAgentType: 'codex', - intent: 'plain-escape' - }) - ).toBe(true) + tabId: 'tab-1', + worktreeId: 'wt-1', + hookEventName: 'Interrupt', + payload: { + state: 'done', + prompt: 'Run sleep 30, then reply done.', + agentType: 'codex', + interrupted: true + } + }, + 'conn-1' + ) vi.setSystemTime(6_000) server.ingestRemote( diff --git a/src/main/agent-hooks/server/server-status-inference.ts b/src/main/agent-hooks/server/server-status-inference.ts index 06f94f619ec..6eae3499a39 100644 --- a/src/main/agent-hooks/server/server-status-inference.ts +++ b/src/main/agent-hooks/server/server-status-inference.ts @@ -2,7 +2,6 @@ import { markClaudeLeadTurnInterrupted, clearClaudeAnsweredQuestionWait } from '../../../shared/agent-hook-listener/providers/claude-roster-state' -import { markCodexLeadTurnInterrupted } from '../../../shared/agent-hook-listener/providers/codex-state' import { isAgentInterruptInputIntent, isNavigationEscapeIntent, @@ -81,13 +80,8 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO this.state.claudeActiveSessionCronPaneKeys.has(existing.paneKey))) // Why: a 'working' pane can be child-driven, and Ctrl+C at the idle prompt of a main agent that // child work holds open cancels nothing, so the main agent fact decides. A row from a host too - // old to publish `mainAgent` keeps the evidence guard, and so does Codex: its synthesized row is - // a plain done, which would retire the live children its combine keeps working. - if ( - payload.mainAgent - ? payload.mainAgent.state !== 'working' || (agentType === 'codex' && childWorkEvidenced) - : childWorkEvidenced - ) { + // old to publish `mainAgent` keeps the evidence guard. + if (payload.mainAgent ? payload.mainAgent.state !== 'working' : childWorkEvidenced) { return false } // Why: whoever owns the provider records folds the cancel with the child work the turn left @@ -101,9 +95,6 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO agentType === 'claude' && existing.connectionId ? foldMainAgentWithRowChildWork('done', existing) : undefined - if (agentType === 'codex') { - markCodexLeadTurnInterrupted(this.state, existing.paneKey) - } const state = local?.state ?? relayed?.stateName ?? 'done' const workingMode = local?.workingMode ?? relayed?.workingMode const inferred = this.applyNormalizedStatus({ diff --git a/src/relay/agent-hook-server-codex-turn-interruption.test.ts b/src/relay/agent-hook-server-codex-turn-interruption.test.ts index 451b21e560b..94808e69918 100644 --- a/src/relay/agent-hook-server-codex-turn-interruption.test.ts +++ b/src/relay/agent-hook-server-codex-turn-interruption.test.ts @@ -56,6 +56,22 @@ it('forwards host-confirmed Codex interruption without requiring a local rollout expect(sideResponse.status).toBe(204) expect(forward).toHaveBeenCalledTimes(1) + const baseline = desktop.getStatusSnapshot()[0] + for (const inputCount of [1, 2]) { + expect( + desktop.inferInterrupt({ + paneKey: PANE_KEY, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: baseline.prompt, + baselineAgentType: 'codex', + intent: 'plain-escape', + inputCount + }) + ).toBe(false) + expect(desktop.getStatusSnapshot()[0]).toEqual(baseline) + } + appendFileSync( transcriptPath, line({ type: 'turn_aborted', turn_id: 'turn-1', reason: 'interrupted' }) diff --git a/src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts index 8e2cd48de6e..4bf4d94bbd6 100644 --- a/src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts @@ -14,7 +14,7 @@ function makeEntry(overrides: Partial = {}): AgentStatusEntry prompt: 'write tests', updatedAt: 1_000, stateStartedAt: 900, - agentType: 'codex', + agentType: 'custom-agent', paneKey: PANE_KEY, terminalTitle: 'Codex', stateHistory: [], @@ -72,8 +72,7 @@ describe('agent interrupt inference', () => { it.each([ ['plain-escape', 'gemini'], - ['ctrl-c', 'gemini'], - ['plain-escape', 'codex'] + ['ctrl-c', 'gemini'] ] as const)('emits a strict baseline request for %s from %s immediately', (intent, agentType) => { vi.useFakeTimers() let entry: AgentStatusEntry | undefined = makeEntry({ agentType }) @@ -321,8 +320,8 @@ describe('agent interrupt inference', () => { entry = undefined }) - it.each([['claude'], ['omp'], ['pi'], ['prime-agent']] as const)( - 'never asks main to interrupt %s on a single Escape while working', + it.each([['claude'], ['codex'], ['omp'], ['pi'], ['prime-agent']] as const)( + 'never asks main to interrupt %s on repeated navigation Escape while working', (agentType) => { // Why: Escape is ambiguous at the source for these TUIs, so the renderer does not spend a // round-trip on it. main re-checks the same rule for requests that never came from here. @@ -336,8 +335,10 @@ describe('agent interrupt inference', () => { now: () => 1_100 }) + tracker.observeInputIntent('plain-escape') tracker.observeInputIntent('plain-escape') vi.advanceTimersByTime(500) + expect(tracker.flushPending()).toBe(false) expect(inferInterrupt).not.toHaveBeenCalled() tracker.dispose() @@ -493,7 +494,7 @@ describe('agent interrupt inference', () => { baselineUpdatedAt: 1_000, baselineStateStartedAt: 900, baselinePrompt: 'write tests', - baselineAgentType: 'codex', + baselineAgentType: 'custom-agent', intent: 'plain-escape' }) tracker.dispose() @@ -580,7 +581,7 @@ describe('agent interrupt inference', () => { baselineUpdatedAt: 2_000, baselineStateStartedAt: 1_900, baselinePrompt: 'newer task', - baselineAgentType: 'codex', + baselineAgentType: 'custom-agent', intent: 'plain-escape' }) } diff --git a/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts index bb9661c362a..a156c456d8c 100644 --- a/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts +++ b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts @@ -46,8 +46,7 @@ function shouldFlushInterruptImmediately( ): boolean { return ( requiresDoubleEscapeInterrupt(baseline.agentType, baseline.intent) || - baseline.agentType === 'gemini' || - (baseline.agentType === 'codex' && baseline.intent === 'plain-escape') + baseline.agentType === 'gemini' ) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts index f1f56056ab9..35bb4fd7a4e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts @@ -747,96 +747,107 @@ describe('connectPanePty', () => { expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') }) - it('pins interrupt inference before acknowledged input and command exit cleanup', async () => { - const { connectPanePty } = await import('./pty-connection') + it.each(['gemini', 'codex'] as const)( + 'pins %s interrupt policy before acknowledged input and command exit cleanup', + async (agentType) => { + const { connectPanePty } = await import('./pty-connection') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - const transport = createMockTransport() - const writeAccepted = createDeferred() - transport.sendInputAccepted = vi.fn(() => writeAccepted.promise) - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return { id: 'tab-pty' } - }) - transport.attach.mockImplementation(({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - }) - transportFactoryQueue.push(transport) - vi.useFakeTimers() - vi.setSystemTime(1_100) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState = { - ...mockStoreState, - agentStatusByPaneKey: { - [paneKey]: { - paneKey, - state: 'working', - prompt: 'stop quickly', - updatedAt: 1_000, - stateStartedAt: 900, - agentType: 'codex', - terminalTitle: 'Codex', - stateHistory: [] + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport() + const writeAccepted = createDeferred() + transport.sendInputAccepted = vi.fn(() => writeAccepted.promise) + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return { id: 'tab-pty' } } - } - } - vi.mocked(window.api.agentStatus.inferInterrupt).mockImplementation(async () => { - mockStoreState.agentStatusByPaneKey[paneKey] = { - paneKey, - state: 'done', - prompt: 'stop quickly', - updatedAt: 1_100, - stateStartedAt: 1_100, - agentType: 'codex', - terminalTitle: 'Codex', - interrupted: true, - stateHistory: [ - { + ) + transport.attach.mockImplementation(({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + }) + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + agentStatusByPaneKey: { + [paneKey]: { + paneKey, state: 'working', prompt: 'stop quickly', - startedAt: 900 + updatedAt: 1_000, + stateStartedAt: 900, + agentType, + terminalTitle: agentType, + stateHistory: [] } - ] + } } - return true - }) - const terminalTarget = createKeyboardEventTarget() - const pane = createPane(1) - ;(pane.terminal as { element?: unknown }).element = terminalTarget.target - let onDataHandler: ((data: string) => void) | null = null - pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { - onDataHandler = handler - return { dispose: vi.fn() } - }) as typeof pane.terminal.onData) + vi.mocked(window.api.agentStatus.inferInterrupt).mockImplementation(async () => { + mockStoreState.agentStatusByPaneKey[paneKey] = { + paneKey, + state: 'done', + prompt: 'stop quickly', + updatedAt: 1_100, + stateStartedAt: 1_100, + agentType, + terminalTitle: agentType, + interrupted: true, + stateHistory: [ + { + state: 'working', + prompt: 'stop quickly', + startedAt: 900 + } + ] + } + return true + }) + const terminalTarget = createKeyboardEventTarget() + const pane = createPane(1) + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) - connectPanePty(pane as never, createManager(1) as never, createDeps() as never) - vi.advanceTimersByTime(1_000) - await flushAsyncTicks() - expect(capturedDataCallback.current).not.toBeNull() - if (!onDataHandler) { - throw new Error('expected onData handler to be registered') + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + vi.advanceTimersByTime(1_000) + await flushAsyncTicks() + expect(capturedDataCallback.current).not.toBeNull() + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + terminalTarget.dispatch(keyEvent({ key: 'Escape' })) + ;(onDataHandler as unknown as (data: string) => void)('\x1b') + + capturedDataCallback.current?.('\x1b]133;D;130\x07thebr ~/repo $ ') + expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() + expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() + + delete mockStoreState.agentStatusByPaneKey[paneKey] + writeAccepted.resolve(true) + await flushAsyncTicks() + + if (agentType === 'codex') { + expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() + expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() + expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(paneKey) + } else { + expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalledWith({ + paneKey, + baselineUpdatedAt: 1_000, + baselineStateStartedAt: 900, + baselinePrompt: 'stop quickly', + baselineAgentType: agentType, + intent: 'plain-escape' + }) + expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) + } } - terminalTarget.dispatch(keyEvent({ key: 'Escape' })) - ;(onDataHandler as unknown as (data: string) => void)('\x1b') - - capturedDataCallback.current?.('\x1b]133;D;130\x07thebr ~/repo $ ') - expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() - expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() - - delete mockStoreState.agentStatusByPaneKey[paneKey] - writeAccepted.resolve(true) - await flushAsyncTicks() - - expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalledWith({ - paneKey, - baselineUpdatedAt: 1_000, - baselineStateStartedAt: 900, - baselinePrompt: 'stop quickly', - baselineAgentType: 'codex', - intent: 'plain-escape' - }) - expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) - }) + ) it('drops the command-finished status when pending interrupt inference is rejected', async () => { const { connectPanePty } = await import('./pty-connection') diff --git a/src/shared/agent-interrupt-intent.ts b/src/shared/agent-interrupt-intent.ts index 70ce0d81b26..75976800d73 100644 --- a/src/shared/agent-interrupt-intent.ts +++ b/src/shared/agent-interrupt-intent.ts @@ -26,12 +26,10 @@ export function shouldIgnoreInterruptIntent( return intent === 'ctrl-c' && (agentType === 'codex' || agentType === 'droid') } -// Why: these TUIs also close an overlay on a bare Escape (Claude's /btw composer, OMP/Pi's -// focused-child and settings views). The keypress is ambiguous at the source and nothing outside -// the TUI can disambiguate it, so it is never evidence a turn ended — only the provider's own -// hook may retire the row (#13547, #9208). Ctrl+C is unaffected; it has no navigation meaning. +// Escape also closes views (including Codex search and /permissions); only provider evidence ends the turn. const ESCAPE_ALSO_NAVIGATES_AGENT_TYPES: ReadonlySet = new Set([ 'claude', + 'codex', 'omp', 'pi', 'prime-agent' diff --git a/tests/e2e/codex-ctrl-c-status.spec.ts b/tests/e2e/codex-ctrl-c-status.spec.ts index 74395ff8e7e..4ad240f1ad4 100644 --- a/tests/e2e/codex-ctrl-c-status.spec.ts +++ b/tests/e2e/codex-ctrl-c-status.spec.ts @@ -18,111 +18,129 @@ import { waitForActiveTerminalManager } from './helpers/terminal' -test('Codex Ctrl+C preserves working status until a confirmed interruption', async ({ - orcaPage, - electronApp -}, testInfo) => { - await waitForSessionReady(orcaPage) - await waitForActiveWorktree(orcaPage) - await ensureTerminalVisible(orcaPage) - await waitForActiveTerminalManager(orcaPage, 30_000) - const endpoint = await readHookEndpoint(electronApp) - const descriptor = await waitForActivePaneHookDescriptor(orcaPage) - await orcaPage.evaluate(() => { - const state = window.__store?.getState() - state?.setAgentActivityDisplayMode('full') - if (state && !state.worktreeCardProperties.includes('inline-agents')) { - state.setWorktreeCardProperties([...state.worktreeCardProperties, 'inline-agents']) - } - }) - const dir = mkdtempSync(join(tmpdir(), 'orca-codex-interruption-')) - const transcriptPath = join(dir, 'rollout-root.jsonl') - writeFileSync( - transcriptPath, - `${JSON.stringify({ type: 'event_msg', payload: { type: 'task_started', turn_id: 'turn-1' } })}\n` - ) - try { - const inputScript = join(dir, 'codex-input.cjs') +// Replay navigation input through a real PTY; hook and rollout fixtures own agent status. +for (const navigation of ['Control+c', 'F3 search', '/permissions'] as const) { + test(`Codex ${navigation} preserves working status until a confirmed interruption`, async ({ + orcaPage, + electronApp + }, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const endpoint = await readHookEndpoint(electronApp) + const descriptor = await waitForActivePaneHookDescriptor(orcaPage) + await orcaPage.evaluate(() => { + const state = window.__store?.getState() + state?.setAgentActivityDisplayMode('full') + if (state && !state.worktreeCardProperties.includes('inline-agents')) { + state.setWorktreeCardProperties([...state.worktreeCardProperties, 'inline-agents']) + } + }) + const dir = mkdtempSync(join(tmpdir(), 'orca-codex-interruption-')) + const transcriptPath = join(dir, 'rollout-root.jsonl') writeFileSync( - inputScript, - ` + transcriptPath, + `${JSON.stringify({ type: 'event_msg', payload: { type: 'task_started', turn_id: 'turn-1' } })}\n` + ) + try { + const inputScript = join(dir, 'codex-input.cjs') + writeFileSync( + inputScript, + ` process.stdin.setRawMode(true) process.stdin.resume() process.stdin.on('data', (chunk) => { if (chunk.includes(3)) process.stdout.write('ORCA_CTRL_C_RECEIVED\\n') + if (chunk.toString() === '\\x1bOR' || chunk.toString() === '\\x1b[13~') { + process.stdout.write('ORCA_F3_SEARCH_OPENED\\n') + } + if (chunk.includes(13)) process.stdout.write('ORCA_PERMISSIONS_OPENED\\n') + if (chunk.toString() === '\\x1b') process.stdout.write('ORCA_ESCAPE_RECEIVED: view dismissed; main task continues\\n') }) process.stdout.write('ORCA_CODEX_INPUT_READY\\n') ` - ) - const terminalWindowsShell = await orcaPage.evaluate( - () => window.__store?.getState().settings.terminalWindowsShell - ) - const shell = resolveStartupShell( - process.platform, - resolveLocalWindowsAgentStartupShell({ - platform: process.platform, - isRemote: false, - terminalWindowsShell + ) + const terminalWindowsShell = await orcaPage.evaluate( + () => window.__store?.getState().settings.terminalWindowsShell + ) + const shell = resolveStartupShell( + process.platform, + resolveLocalWindowsAgentStartupShell({ + platform: process.platform, + isRemote: false, + terminalWindowsShell + }) + ) + const ptyId = await waitForActivePanePtyId(orcaPage) + await execInTerminal( + orcaPage, + ptyId, + buildShellCommandFromArgv([process.execPath, inputScript], shell) + ) + await waitForTerminalOutput(orcaPage, 'ORCA_CODEX_INPUT_READY') + await emitCodexHookStatus(endpoint, { + ...descriptor, + transcriptPath, + sessionId: 'main-session', + state: 'working', + prompt: 'Main task continues' }) - ) - const ptyId = await waitForActivePanePtyId(orcaPage) - await execInTerminal( - orcaPage, - ptyId, - buildShellCommandFromArgv([process.execPath, inputScript], shell) - ) - await waitForTerminalOutput(orcaPage, 'ORCA_CODEX_INPUT_READY') - await emitCodexHookStatus(endpoint, { - ...descriptor, - transcriptPath, - sessionId: 'main-session', - state: 'working', - prompt: 'Main task continues' - }) - const working = orcaPage.locator('[aria-label="Working"]') - const interrupted = orcaPage.locator('[aria-label="Interrupted"]') - await expect(working.first()).toBeVisible() - await emitCodexHookStatus(endpoint, { - ...descriptor, - state: 'working', - sessionId: 'side-session', - transcriptPath: null, - prompt: 'Side chat' - }) - await expect(orcaPage.getByText('Main task continues', { exact: true })).toBeVisible() - await focusActiveTerminalInput(orcaPage) - await orcaPage.keyboard.press('Control+c') - await waitForTerminalOutput(orcaPage, 'ORCA_CTRL_C_RECEIVED') - // Allow the old 500 ms inference timer to fire before recording the rendered result. - await orcaPage.waitForTimeout(1_000) - await orcaPage.screenshot({ - path: testInfo.outputPath('status-after-ctrl-c.png'), - clip: { x: 0, y: 180, width: 280, height: 240 } - }) - await expect(interrupted).toHaveCount(0) - await expect(working.first()).toBeVisible() + const working = orcaPage.locator('[aria-label="Working"]') + const interrupted = orcaPage.locator('[aria-label="Interrupted"]') + await expect(working.first()).toBeVisible() + await emitCodexHookStatus(endpoint, { + ...descriptor, + state: 'working', + sessionId: 'side-session', + transcriptPath: null, + prompt: 'Side chat' + }) + await expect(orcaPage.getByText('Main task continues', { exact: true })).toBeVisible() + await focusActiveTerminalInput(orcaPage) + const key = navigation === 'Control+c' ? 'Control+c' : 'Escape' + if (navigation === 'F3 search') { + await orcaPage.keyboard.press('F3') + await waitForTerminalOutput(orcaPage, 'ORCA_F3_SEARCH_OPENED') + } else if (navigation === '/permissions') { + await orcaPage.keyboard.type('/permissions') + await orcaPage.keyboard.press('Enter') + await waitForTerminalOutput(orcaPage, 'ORCA_PERMISSIONS_OPENED') + } + await orcaPage.keyboard.press(key) + await waitForTerminalOutput( + orcaPage, + key === 'Escape' ? 'ORCA_ESCAPE_RECEIVED' : 'ORCA_CTRL_C_RECEIVED' + ) + // Allow the old 500 ms inference timer to fire before recording the rendered result. + await orcaPage.waitForTimeout(1_000) + await orcaPage.screenshot({ + path: testInfo.outputPath('status-after-input.png') + }) + await expect(interrupted).toHaveCount(0) + await expect(working.first()).toBeVisible() - appendFileSync( - transcriptPath, - `${JSON.stringify({ type: 'event_msg', payload: { type: 'turn_aborted', turn_id: 'turn-1', reason: 'interrupted' } })}\n` - ) - await expect(interrupted.first()).toBeVisible() - await expect(working).toHaveCount(0) - await orcaPage.screenshot({ - path: testInfo.outputPath('status-after-confirmed-interruption.png'), - clip: { x: 0, y: 180, width: 280, height: 240 } - }) - await emitCodexHookStatus(endpoint, { - ...descriptor, - state: 'working', - prompt: 'Next main task' - }) - await expect(working.first()).toBeVisible() - await expect(interrupted).toHaveCount(0) - await emitCodexHookStatus(endpoint, { ...descriptor, state: 'done' }) - await expect(working).toHaveCount(0) - await expect(interrupted).toHaveCount(0) - } finally { - rmSync(dir, { recursive: true, force: true }) - } -}) + appendFileSync( + transcriptPath, + `${JSON.stringify({ type: 'event_msg', payload: { type: 'turn_aborted', turn_id: 'turn-1', reason: 'interrupted' } })}\n` + ) + await expect(interrupted.first()).toBeVisible() + await expect(working).toHaveCount(0) + await orcaPage.screenshot({ + path: testInfo.outputPath('status-after-confirmed-interruption.png') + }) + await emitCodexHookStatus(endpoint, { + ...descriptor, + state: 'working', + prompt: 'Next main task' + }) + await expect(working.first()).toBeVisible() + await expect(interrupted).toHaveCount(0) + await emitCodexHookStatus(endpoint, { ...descriptor, state: 'done' }) + await expect(working).toHaveCount(0) + await expect(interrupted).toHaveCount(0) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}