mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 08:02:31 +00:00
fix(agent-status): preserve Codex escape interruption (#14372)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -15,7 +15,7 @@ export type AgentInterruptInference = {
|
||||
intent: AgentInterruptInputIntent,
|
||||
entry?: AgentStatusEntry | null,
|
||||
baselineSequence?: number
|
||||
): void
|
||||
): boolean | Promise<boolean> | undefined
|
||||
flushPending(): boolean | Promise<boolean>
|
||||
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() {
|
||||
|
||||
@@ -2015,10 +2015,10 @@ export function connectPanePty(
|
||||
// "question answered" signal no hook will ever deliver.
|
||||
questionAnsweredInference.observeSentTerminalInput(data)
|
||||
}
|
||||
let pendingTerminalInputWrite: Promise<void> | null = null
|
||||
let pendingTerminalInputWrite: Promise<boolean | null> | null = null
|
||||
let sequencedInterruptStatusBaseline: AgentStatusEntry | null | undefined
|
||||
let interruptStatusBaselineSequence = 0
|
||||
const setPendingTerminalInputWrite = (promise: Promise<void>): void => {
|
||||
const setPendingTerminalInputWrite = (promise: Promise<boolean | null>): 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<boolean> | 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
|
||||
|
||||
Reference in New Issue
Block a user