fix(agent-status): preserve Codex escape interruption (#14372)

This commit is contained in:
Brennan Benson
2026-08-13 16:19:26 -07:00
committed by GitHub
parent a27d046e5e
commit c3b8c145e2
5 changed files with 144 additions and 43 deletions
+68
View File
@@ -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', () => { it('allows a new prompt after an inferred interrupt', () => {
vi.useFakeTimers() vi.useFakeTimers()
vi.setSystemTime(1_000) vi.setSystemTime(1_000)
+4 -1
View File
@@ -448,7 +448,7 @@ function isToolProgressWorkingAfterInterrupt(next: AgentHookEventPayload): boole
if (next.payload.state !== 'working') { if (next.payload.state !== 'working') {
return false return false
} }
if (next.payload.agentType !== 'claude') { if (next.payload.agentType !== 'claude' && next.payload.agentType !== 'codex') {
return false return false
} }
// Why: a same-prompt retry is another UserPromptSubmit, while late post-Ctrl+C progress arrives as tool lifecycle work. // 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 && (effectivePayload.hasExplicitPrompt !== true &&
Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS)) Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS))
) { ) {
if (effectivePayload.payload.agentType === 'codex') {
markCodexLeadTurnInterrupted(this.state, effectivePayload.paneKey)
}
return previous return previous
} }
if ( if (
@@ -72,34 +72,59 @@ describe('agent interrupt inference', () => {
it.each([ it.each([
['plain-escape', 'gemini'], ['plain-escape', 'gemini'],
['ctrl-c', 'gemini'] ['ctrl-c', 'gemini'],
] as const)( ['plain-escape', 'codex']
'emits a strict baseline request for %s from Gemini immediately', ] as const)('emits a strict baseline request for %s from %s immediately', (intent, agentType) => {
(intent, agentType) => { vi.useFakeTimers()
vi.useFakeTimers() let entry: AgentStatusEntry | undefined = makeEntry({ agentType })
let entry: AgentStatusEntry | undefined = makeEntry({ agentType }) const inferInterrupt = vi.fn()
const inferInterrupt = vi.fn() const tracker = createAgentInterruptInference({
const tracker = createAgentInterruptInference({ paneKey: PANE_KEY,
paneKey: PANE_KEY, getStatusEntry: () => entry,
getStatusEntry: () => entry, inferInterrupt,
inferInterrupt, now: () => 1_100
now: () => 1_100 })
})
tracker.observeInputIntent(intent) tracker.observeInputIntent(intent)
expect(inferInterrupt).toHaveBeenCalledWith({ expect(inferInterrupt).toHaveBeenCalledWith({
paneKey: PANE_KEY, paneKey: PANE_KEY,
baselineUpdatedAt: 1_000, baselineUpdatedAt: 1_000,
baselineStateStartedAt: 900, baselineStateStartedAt: 900,
baselinePrompt: 'write tests', baselinePrompt: 'write tests',
baselineAgentType: agentType, baselineAgentType: agentType,
intent intent
}) })
tracker.dispose() tracker.dispose()
entry = undefined 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', () => { it('reports Escape while Claude is waiting on AskUserQuestion', () => {
vi.useFakeTimers() vi.useFakeTimers()
@@ -344,7 +369,7 @@ describe('agent interrupt inference', () => {
it('cancels when a newer hook update arrives during the settle window', () => { it('cancels when a newer hook update arrives during the settle window', () => {
vi.useFakeTimers() vi.useFakeTimers()
const inferInterrupt = vi.fn() const inferInterrupt = vi.fn()
let entry: AgentStatusEntry | undefined = makeEntry() let entry: AgentStatusEntry | undefined = makeEntry({ agentType: 'custom-agent' })
const tracker = createAgentInterruptInference({ const tracker = createAgentInterruptInference({
paneKey: PANE_KEY, paneKey: PANE_KEY,
getStatusEntry: () => entry, getStatusEntry: () => entry,
@@ -15,7 +15,7 @@ export type AgentInterruptInference = {
intent: AgentInterruptInputIntent, intent: AgentInterruptInputIntent,
entry?: AgentStatusEntry | null, entry?: AgentStatusEntry | null,
baselineSequence?: number baselineSequence?: number
): void ): boolean | Promise<boolean> | undefined
flushPending(): boolean | Promise<boolean> flushPending(): boolean | Promise<boolean>
dispose(): void dispose(): void
} }
@@ -50,7 +50,8 @@ function shouldFlushInterruptImmediately(
): boolean { ): boolean {
return ( return (
requiresDoubleEscapeForAgent(baseline.agentType, baseline.intent) || 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 pendingBaseline = baseline
if (shouldFlushInterruptImmediately(baseline)) { if (shouldFlushInterruptImmediately(baseline)) {
// Why: these agents can emit their idle/done hook immediately after an // Why: these interrupts can emit an idle/done hook before the settle timer,
// accepted interrupt. Flush before that hook overwrites the working baseline. // overwriting the working baseline and losing the interrupted outcome.
void flushPending() return flushPending()
return
} }
pendingTimer = setTimer(flushPendingFromTimer, AGENT_INTERRUPT_SETTLE_MS) pendingTimer = setTimer(flushPendingFromTimer, AGENT_INTERRUPT_SETTLE_MS)
return undefined
}, },
flushPending, flushPending,
dispose() { dispose() {
@@ -2015,10 +2015,10 @@ export function connectPanePty(
// "question answered" signal no hook will ever deliver. // "question answered" signal no hook will ever deliver.
questionAnsweredInference.observeSentTerminalInput(data) questionAnsweredInference.observeSentTerminalInput(data)
} }
let pendingTerminalInputWrite: Promise<void> | null = null let pendingTerminalInputWrite: Promise<boolean | null> | null = null
let sequencedInterruptStatusBaseline: AgentStatusEntry | null | undefined let sequencedInterruptStatusBaseline: AgentStatusEntry | null | undefined
let interruptStatusBaselineSequence = 0 let interruptStatusBaselineSequence = 0
const setPendingTerminalInputWrite = (promise: Promise<void>): void => { const setPendingTerminalInputWrite = (promise: Promise<boolean | null>): void => {
pendingTerminalInputWrite = promise pendingTerminalInputWrite = promise
void promise.finally(() => { void promise.finally(() => {
if (pendingTerminalInputWrite === promise) { if (pendingTerminalInputWrite === promise) {
@@ -2031,7 +2031,9 @@ export function connectPanePty(
if (!pendingWrite) { if (!pendingWrite) {
return interruptInference.flushPending() 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 // 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 // "does this pane expect an agent"; derive each signal once so the two callers
@@ -4166,26 +4168,28 @@ export function connectPanePty(
clearPendingTerminalInputIntent() clearPendingTerminalInputIntent()
const writePromise = transport const writePromise = transport
.sendInputAccepted(data) .sendInputAccepted(data)
.then((accepted) => { .then((accepted): boolean | Promise<boolean> | null => {
if (accepted) { if (accepted) {
// Why: rejected writes use transport recovery and must not arm a parser probe. // Why: rejected writes use transport recovery and must not arm a parser probe.
markAcceptedTerminalInputSent() markAcceptedTerminalInputSent()
observeAcceptedShellCommandInput(data) observeAcceptedShellCommandInput(data)
observeAcceptedTerminalInput(data, acknowledgedIntent) observeAcceptedTerminalInput(data, acknowledgedIntent)
interruptInference.observeInputIntent( const immediateResult = interruptInference.observeInputIntent(
acknowledgedIntent, acknowledgedIntent,
interruptStatusBaseline, interruptStatusBaseline,
capturedBaselineSequence capturedBaselineSequence
) )
observeTitleOnlyInterrupt() observeTitleOnlyInterrupt()
} else { 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()
} }
// 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) => { .catch((err) => {
console.warn('[agent-interrupt] acknowledged terminal input failed:', err) console.warn('[agent-interrupt] acknowledged terminal input failed:', err)
return null
}) })
setPendingTerminalInputWrite(writePromise) setPendingTerminalInputWrite(writePromise)
return return