From 03b4f8675970cbe9de50facab790202fe7736c8c Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Mon, 18 May 2026 16:04:44 -0700 Subject: [PATCH] Handle agent-specific interrupt status edge cases Co-authored-by: Orca --- src/main/agent-hooks/server.test.ts | 193 ++++++++++++++++++ src/main/agent-hooks/server.ts | 21 ++ .../agent-interrupt-inference.test.ts | 108 ++++++++++ .../agent-interrupt-inference.ts | 48 ++++- src/shared/agent-interrupt-intent.ts | 1 + 5 files changed, 367 insertions(+), 4 deletions(-) diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 91cc79b2177..5114eccfea6 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -167,6 +167,199 @@ describe('AgentHookServer listener replay', () => { } }) + it('rejects single plain Escape inference for OpenCode', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'long task', agentType: 'opencode' } + }, + '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: 'opencode', + intent: 'plain-escape' + }) + + expect(applied).toBe(false) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + prompt: 'long task', + agentType: 'opencode' + }) + ]) + } finally { + vi.useRealTimers() + } + }) + + it('accepts double plain Escape inference for OpenCode', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'long task', agentType: 'opencode' } + }, + '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: 'opencode', + intent: 'plain-escape', + inputCount: 2 + }) + + expect(applied).toBe(true) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'done', + prompt: 'long task', + agentType: 'opencode', + interrupted: true + }) + ]) + } finally { + vi.useRealTimers() + } + }) + + it('does not let late same-turn working hooks 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', + payload: { state: 'working', prompt: 'long task', agentType: 'pi' } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'long task', + baselineAgentType: 'pi', + intent: 'ctrl-c' + }) + ).toBe(true) + + vi.setSystemTime(6_000) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { + state: 'working', + prompt: 'long task', + agentType: 'pi', + toolName: 'bash', + toolInput: '/bin/sleep 90' + } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'done', + prompt: 'long task', + agentType: 'pi', + 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) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'first task', agentType: 'pi' } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'first task', + baselineAgentType: 'pi', + intent: 'ctrl-c' + }) + ).toBe(true) + + vi.setSystemTime(2_000) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'second task', agentType: 'pi' } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + prompt: 'second task', + agentType: 'pi', + receivedAt: 2_000, + stateStartedAt: 2_000 + }) + ]) + } finally { + vi.useRealTimers() + } + }) + it('rejects inferred interrupts for stale and non-working rows', () => { vi.useFakeTimers() vi.setSystemTime(1_000) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 6a2c3207b0e..a346e420553 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -84,6 +84,7 @@ type PaneKeyAliasEntry = { const LAST_STATUS_FILE_NAME = 'last-status.json' const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5 const ASSISTANT_MESSAGE_RETRY_MS = 50 +const INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS = 15_000 // Why: starts at 2 (not 1) because pre-merge dev iterations of this branch // wrote a v1 shape with no receivedAt / stateStartedAt. Bumping to 2 means a @@ -300,6 +301,11 @@ export class AgentHookServer { } const payload = existing.payload const agentType: AgentType | undefined = payload.agentType + // Why: OpenCode's running-turn interrupt is double Escape. A single Escape + // is only a TUI/editor cancel and can leave the turn running. + if (agentType === 'opencode' && request.intent === 'plain-escape' && request.inputCount !== 2) { + return false + } // Why: input-intent inference is a fallback for a missing final hook. A strict // baseline match keeps a delayed timer from overwriting any newer hook, // including same-millisecond prompt or agent identity changes. @@ -374,6 +380,21 @@ export class AgentHookServer { } private applyNormalizedStatus(payload: AgentHookEventPayload): EnrichedAgentHookEventPayload { + const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as + | EnrichedAgentHookEventPayload + | undefined + // Why: some TUIs can emit a delayed tool/working hook after Ctrl+C already + // stopped the turn. Do not let that stale same-turn event resurrect the row. + if ( + previous?.payload.state === 'done' && + previous.payload.interrupted === true && + payload.payload.state === 'working' && + previous.payload.agentType === payload.payload.agentType && + previous.payload.prompt === payload.payload.prompt && + Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS + ) { + return previous + } if (payload.payload.state !== 'done' || payload.payload.lastAssistantMessage) { this.clearAssistantMessageRetry(payload.paneKey) } 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 a3e89618b19..83cb4b4f3d7 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 @@ -84,6 +84,114 @@ describe('agent interrupt inference', () => { entry = undefined }) + it('requires double Escape on the same turn for OpenCode', () => { + vi.useFakeTimers() + let entry: AgentStatusEntry | undefined = makeEntry({ agentType: 'opencode' }) + const inferInterrupt = vi.fn() + const tracker = createAgentInterruptInference({ + paneKey: PANE_KEY, + getStatusEntry: () => entry, + inferInterrupt, + now: () => 1_100 + }) + + tracker.observeInputIntent('plain-escape') + vi.runOnlyPendingTimers() + expect(inferInterrupt).not.toHaveBeenCalled() + + tracker.observeInputIntent('plain-escape') + vi.advanceTimersByTime(500) + + expect(inferInterrupt).toHaveBeenCalledWith({ + paneKey: PANE_KEY, + baselineUpdatedAt: 1_000, + baselineStateStartedAt: 900, + baselinePrompt: 'write tests', + baselineAgentType: 'opencode', + intent: 'plain-escape', + inputCount: 2 + }) + tracker.dispose() + entry = undefined + }) + + it('does not count an OpenCode Escape across a new turn', () => { + vi.useFakeTimers() + let entry: AgentStatusEntry | undefined = makeEntry({ agentType: 'opencode' }) + const inferInterrupt = vi.fn() + const tracker = createAgentInterruptInference({ + paneKey: PANE_KEY, + getStatusEntry: () => entry, + inferInterrupt, + now: () => 1_100 + }) + + tracker.observeInputIntent('plain-escape') + entry = makeEntry({ agentType: 'opencode', prompt: 'second task', stateStartedAt: 1_050 }) + tracker.observeInputIntent('plain-escape') + vi.runOnlyPendingTimers() + + expect(inferInterrupt).not.toHaveBeenCalled() + tracker.dispose() + entry = undefined + }) + + it('does not emit again for a third OpenCode Escape after the row is already done', () => { + vi.useFakeTimers() + let entry: AgentStatusEntry | undefined = makeEntry({ agentType: 'opencode' }) + const inferInterrupt = vi.fn((request) => { + entry = makeEntry({ + state: 'done', + agentType: request.baselineAgentType, + prompt: request.baselinePrompt, + updatedAt: 1_500, + stateStartedAt: 1_500 + }) + }) + const tracker = createAgentInterruptInference({ + paneKey: PANE_KEY, + getStatusEntry: () => entry, + inferInterrupt, + now: () => 1_100 + }) + + tracker.observeInputIntent('plain-escape') + tracker.observeInputIntent('plain-escape') + vi.advanceTimersByTime(500) + tracker.observeInputIntent('plain-escape') + vi.runOnlyPendingTimers() + + expect(inferInterrupt).toHaveBeenCalledTimes(1) + tracker.dispose() + entry = undefined + }) + + it('still infers Ctrl+C for OpenCode', () => { + vi.useFakeTimers() + let entry: AgentStatusEntry | undefined = makeEntry({ agentType: 'opencode' }) + const inferInterrupt = vi.fn() + const tracker = createAgentInterruptInference({ + paneKey: PANE_KEY, + getStatusEntry: () => entry, + inferInterrupt, + now: () => 1_100 + }) + + tracker.observeInputIntent('ctrl-c') + vi.advanceTimersByTime(500) + + expect(inferInterrupt).toHaveBeenCalledWith({ + paneKey: PANE_KEY, + baselineUpdatedAt: 1_000, + baselineStateStartedAt: 900, + baselinePrompt: 'write tests', + baselineAgentType: 'opencode', + intent: 'ctrl-c' + }) + tracker.dispose() + entry = undefined + }) + it('does not emit for non-working states', () => { vi.useFakeTimers() const inferInterrupt = vi.fn() 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 192f3b271da..68c3c460f51 100644 --- a/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts +++ b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts @@ -29,6 +29,25 @@ type CapturedInterruptBaseline = { prompt: string agentType: AgentStatusEntry['agentType'] intent: AgentInterruptInputIntent + inputCount?: number +} + +function requiresDoubleEscapeForAgent( + agentType: AgentStatusEntry['agentType'], + intent: AgentInterruptInputIntent +): boolean { + return agentType === 'opencode' && intent === 'plain-escape' +} + +function isSameTurnBaseline( + left: CapturedInterruptBaseline, + right: CapturedInterruptBaseline +): boolean { + return ( + left.agentType === right.agentType && + left.prompt === right.prompt && + left.stateStartedAt === right.stateStartedAt + ) } export function isPlainEscapeKeyEvent( @@ -67,8 +86,9 @@ export function createAgentInterruptInference({ }: AgentInterruptInferenceDeps): AgentInterruptInference { let pendingTimer: ReturnType | null = null let pendingBaseline: CapturedInterruptBaseline | null = null + let openCodeEscapeBaseline: CapturedInterruptBaseline | null = null - const clearPending = (): void => { + const clearPendingTimer = (): void => { if (pendingTimer !== null) { clearTimer(pendingTimer) pendingTimer = null @@ -76,6 +96,11 @@ export function createAgentInterruptInference({ pendingBaseline = null } + const clearPending = (): void => { + clearPendingTimer() + openCodeEscapeBaseline = null + } + const captureBaseline = ( entry: AgentStatusEntry, intent: AgentInterruptInputIntent @@ -123,7 +148,8 @@ export function createAgentInterruptInference({ baselineStateStartedAt: baseline.stateStartedAt, baselinePrompt: baseline.prompt, baselineAgentType: baseline.agentType, - intent: baseline.intent + intent: baseline.intent, + ...(baseline.inputCount !== undefined ? { inputCount: baseline.inputCount } : {}) }) } @@ -134,12 +160,26 @@ export function createAgentInterruptInference({ clearPending() return } - const baseline = captureBaseline(entry, intent) + let baseline = captureBaseline(entry, intent) if (!baseline) { clearPending() return } - clearPending() + if (requiresDoubleEscapeForAgent(baseline.agentType, intent)) { + const isSecondEscape = + openCodeEscapeBaseline !== null && isSameTurnBaseline(openCodeEscapeBaseline, baseline) + openCodeEscapeBaseline = baseline + clearPendingTimer() + if (!isSecondEscape) { + return + } + // Why: OpenCode uses the first Escape as a TUI/editor cancel. The + // second Escape on the same turn is the actual running-turn interrupt. + baseline = { ...baseline, inputCount: 2 } + } else { + openCodeEscapeBaseline = null + clearPendingTimer() + } pendingBaseline = baseline pendingTimer = setTimer(flushPending, AGENT_INTERRUPT_SETTLE_MS) }, diff --git a/src/shared/agent-interrupt-intent.ts b/src/shared/agent-interrupt-intent.ts index 25c2fcb8920..d058afa8049 100644 --- a/src/shared/agent-interrupt-intent.ts +++ b/src/shared/agent-interrupt-intent.ts @@ -11,6 +11,7 @@ export type AgentInterruptInferenceRequest = { baselinePrompt: string baselineAgentType: AgentType | undefined intent: AgentInterruptInputIntent + inputCount?: number } export function isAgentInterruptInputIntent(intent: unknown): intent is AgentInterruptInputIntent {