diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index c1870d35a7c..34ef9b68db1 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -1070,6 +1070,74 @@ describe('AgentHookServer listener replay', () => { } }) + it('does not let late Codex tool hooks with explicit prompt resurrect an inferred interrupt', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + hookEventName: 'UserPromptSubmit', + payload: { + state: 'working', + prompt: 'Run sleep 30, then reply done.', + agentType: 'codex' + } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'Run sleep 30, then reply done.', + baselineAgentType: 'codex', + intent: 'plain-escape' + }) + ).toBe(true) + + vi.setSystemTime(6_000) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + hookEventName: 'PostToolUse', + payload: { + state: 'working', + prompt: 'Run sleep 30, then reply done.', + agentType: 'codex', + toolName: 'Bash', + toolInput: 'sleep 30' + } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'done', + prompt: 'Run sleep 30, then reply done.', + agentType: 'codex', + interrupted: true, + receivedAt: 1_500, + stateStartedAt: 1_500 + }) + ]) + } finally { + vi.useRealTimers() + } + }) + it('allows a new prompt after an inferred interrupt', () => { vi.useFakeTimers() vi.setSystemTime(1_000) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 4dc64d523de..8a10080168c 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -448,7 +448,7 @@ function isToolProgressWorkingAfterInterrupt(next: AgentHookEventPayload): boole if (next.payload.state !== 'working') { return false } - if (next.payload.agentType !== 'claude') { + if (next.payload.agentType !== 'claude' && next.payload.agentType !== 'codex') { return false } // Why: a same-prompt retry is another UserPromptSubmit, while late post-Ctrl+C progress arrives as tool lifecycle work. @@ -1260,6 +1260,9 @@ export class AgentHookServer { (effectivePayload.hasExplicitPrompt !== true && Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS)) ) { + if (effectivePayload.payload.agentType === 'codex') { + markCodexLeadTurnInterrupted(this.state, effectivePayload.paneKey) + } return previous } if ( 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 98cc941aadd..9bab0bd5005 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 @@ -72,34 +72,59 @@ describe('agent interrupt inference', () => { it.each([ ['plain-escape', 'gemini'], - ['ctrl-c', 'gemini'] - ] as const)( - 'emits a strict baseline request for %s from Gemini immediately', - (intent, agentType) => { - vi.useFakeTimers() - let entry: AgentStatusEntry | undefined = makeEntry({ agentType }) - const inferInterrupt = vi.fn() - const tracker = createAgentInterruptInference({ - paneKey: PANE_KEY, - getStatusEntry: () => entry, - inferInterrupt, - now: () => 1_100 - }) + ['ctrl-c', 'gemini'], + ['plain-escape', 'codex'] + ] as const)('emits a strict baseline request for %s from %s immediately', (intent, agentType) => { + vi.useFakeTimers() + let entry: AgentStatusEntry | undefined = makeEntry({ agentType }) + const inferInterrupt = vi.fn() + const tracker = createAgentInterruptInference({ + paneKey: PANE_KEY, + getStatusEntry: () => entry, + inferInterrupt, + now: () => 1_100 + }) - tracker.observeInputIntent(intent) + tracker.observeInputIntent(intent) - expect(inferInterrupt).toHaveBeenCalledWith({ - paneKey: PANE_KEY, - baselineUpdatedAt: 1_000, - baselineStateStartedAt: 900, - baselinePrompt: 'write tests', - baselineAgentType: agentType, - intent - }) - tracker.dispose() - entry = undefined - } - ) + expect(inferInterrupt).toHaveBeenCalledWith({ + paneKey: PANE_KEY, + baselineUpdatedAt: 1_000, + baselineStateStartedAt: 900, + baselinePrompt: 'write tests', + baselineAgentType: agentType, + intent + }) + tracker.dispose() + entry = undefined + }) + + it('records a Codex Escape before its immediate done hook replaces the working row', () => { + vi.useFakeTimers() + let entry: AgentStatusEntry | undefined = makeEntry({ agentType: 'codex' }) + const inferInterrupt = vi.fn() + const tracker = createAgentInterruptInference({ + paneKey: PANE_KEY, + getStatusEntry: () => entry, + inferInterrupt, + now: () => 1_100 + }) + + tracker.observeInputIntent('plain-escape') + entry = makeEntry({ state: 'done', updatedAt: 1_101, stateStartedAt: 1_101 }) + vi.advanceTimersByTime(500) + + expect(inferInterrupt).toHaveBeenCalledTimes(1) + expect(inferInterrupt).toHaveBeenCalledWith({ + paneKey: PANE_KEY, + baselineUpdatedAt: 1_000, + baselineStateStartedAt: 900, + baselinePrompt: 'write tests', + baselineAgentType: 'codex', + intent: 'plain-escape' + }) + tracker.dispose() + }) it('reports Escape while Claude is waiting on AskUserQuestion', () => { vi.useFakeTimers() @@ -344,7 +369,7 @@ describe('agent interrupt inference', () => { it('cancels when a newer hook update arrives during the settle window', () => { vi.useFakeTimers() const inferInterrupt = vi.fn() - let entry: AgentStatusEntry | undefined = makeEntry() + let entry: AgentStatusEntry | undefined = makeEntry({ agentType: 'custom-agent' }) const tracker = createAgentInterruptInference({ paneKey: PANE_KEY, getStatusEntry: () => entry, 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 b847badcdfc..8e2f4cecde3 100644 --- a/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts +++ b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts @@ -15,7 +15,7 @@ export type AgentInterruptInference = { intent: AgentInterruptInputIntent, entry?: AgentStatusEntry | null, baselineSequence?: number - ): void + ): boolean | Promise | undefined flushPending(): boolean | Promise dispose(): void } @@ -50,7 +50,8 @@ function shouldFlushInterruptImmediately( ): boolean { return ( requiresDoubleEscapeForAgent(baseline.agentType, baseline.intent) || - baseline.agentType === 'gemini' + baseline.agentType === 'gemini' || + (baseline.agentType === 'codex' && baseline.intent === 'plain-escape') ) } @@ -261,12 +262,12 @@ export function createAgentInterruptInference({ } pendingBaseline = baseline if (shouldFlushInterruptImmediately(baseline)) { - // Why: these agents can emit their idle/done hook immediately after an - // accepted interrupt. Flush before that hook overwrites the working baseline. - void flushPending() - return + // Why: these interrupts can emit an idle/done hook before the settle timer, + // overwriting the working baseline and losing the interrupted outcome. + return flushPending() } pendingTimer = setTimer(flushPendingFromTimer, AGENT_INTERRUPT_SETTLE_MS) + return undefined }, flushPending, dispose() { diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 572d3e68b19..b4a683874ee 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -2015,10 +2015,10 @@ export function connectPanePty( // "question answered" signal no hook will ever deliver. questionAnsweredInference.observeSentTerminalInput(data) } - let pendingTerminalInputWrite: Promise | null = null + let pendingTerminalInputWrite: Promise | null = null let sequencedInterruptStatusBaseline: AgentStatusEntry | null | undefined let interruptStatusBaselineSequence = 0 - const setPendingTerminalInputWrite = (promise: Promise): void => { + const setPendingTerminalInputWrite = (promise: Promise): void => { pendingTerminalInputWrite = promise void promise.finally(() => { if (pendingTerminalInputWrite === promise) { @@ -2031,7 +2031,9 @@ export function connectPanePty( if (!pendingWrite) { return interruptInference.flushPending() } - return pendingWrite.then(() => interruptInference.flushPending()) + return pendingWrite.then((immediateResult) => { + return immediateResult ?? interruptInference.flushPending() + }) } // Why: the 133;D confirmation guard and the visible-pane resampler both key off // "does this pane expect an agent"; derive each signal once so the two callers @@ -4166,26 +4168,28 @@ export function connectPanePty( clearPendingTerminalInputIntent() const writePromise = transport .sendInputAccepted(data) - .then((accepted) => { + .then((accepted): boolean | Promise | null => { if (accepted) { // Why: rejected writes use transport recovery and must not arm a parser probe. markAcceptedTerminalInputSent() observeAcceptedShellCommandInput(data) observeAcceptedTerminalInput(data, acknowledgedIntent) - interruptInference.observeInputIntent( + const immediateResult = interruptInference.observeInputIntent( acknowledgedIntent, interruptStatusBaseline, capturedBaselineSequence ) observeTitleOnlyInterrupt() - } else { - // Why: Esc/Ctrl+C are the first keys users press on a frozen pane; - // an unbound-transport reject here must arm recovery too. - requestRecoveryForUndeliverableInput() + return immediateResult ?? null } + // Why: Esc/Ctrl+C are the first keys users press on a frozen pane; + // an unbound-transport reject here must arm recovery too. + requestRecoveryForUndeliverableInput() + return null }) .catch((err) => { console.warn('[agent-interrupt] acknowledged terminal input failed:', err) + return null }) setPendingTerminalInputWrite(writePromise) return