mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 16:02:24 +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', () => {
|
it('allows a new prompt after an inferred interrupt', () => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
vi.setSystemTime(1_000)
|
vi.setSystemTime(1_000)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user