From db69cd9387447ceb77422df7ef23d5c22b39201b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:43:20 -0700 Subject: [PATCH] fix(agent-status): clear Claude question indicator after Escape (#12064) --- ...server.claude-interactive-question.test.ts | 70 ++++++++++++++++++- src/main/agent-hooks/server.ts | 13 +++- .../agent-interrupt-inference.test.ts | 45 ++++++++++++ .../agent-interrupt-inference.ts | 15 +++- 4 files changed, 137 insertions(+), 6 deletions(-) diff --git a/src/main/agent-hooks/server.claude-interactive-question.test.ts b/src/main/agent-hooks/server.claude-interactive-question.test.ts index cc450a3c36d..8efe3fd7633 100644 --- a/src/main/agent-hooks/server.claude-interactive-question.test.ts +++ b/src/main/agent-hooks/server.claude-interactive-question.test.ts @@ -11,6 +11,7 @@ function ingestClaudeStatus( hookEventName: 'PermissionRequest' | 'PreToolUse' toolName: string toolUseId: string + interactivePrompt?: string } ): void { server.ingestRemote( @@ -23,7 +24,8 @@ function ingestClaudeStatus( payload: { state: event.state, agentType: 'claude', - toolName: event.toolName + toolName: event.toolName, + ...(event.interactivePrompt ? { interactivePrompt: event.interactivePrompt } : {}) } }, 'connection-1' @@ -127,6 +129,72 @@ function answeredRequestFromSnapshot( } } +function escapeRequestFromSnapshot( + server: AgentHookServer +): Parameters[0] { + return { + ...answeredRequestFromSnapshot(server), + intent: 'plain-escape' + } +} + +describe('inferInterrupt for Claude interactive questions', () => { + it.each(['PreToolUse', 'PermissionRequest'] as const)( + 'clears a %s AskUserQuestion wait after Escape', + (hookEventName) => { + const server = new AgentHookServer() + ingestClaudeStatus(server, { + state: 'waiting', + hookEventName, + toolName: 'AskUserQuestion', + toolUseId: 'tool-question', + interactivePrompt: '{"questions":[{"question":"Pick one"}]}' + }) + + expect(server.inferInterrupt(escapeRequestFromSnapshot(server))).toBe(true) + const [entry] = server.getStatusSnapshot() + expect(entry).toMatchObject({ paneKey: PANE_KEY, state: 'working', agentType: 'claude' }) + expect(entry.toolName).toBeUndefined() + expect(entry.interactivePrompt).toBeUndefined() + expect(entry.interrupted).toBeUndefined() + } + ) + + it('rejects Escape when the question baseline is stale', () => { + const server = new AgentHookServer() + ingestClaudeStatus(server, { + state: 'waiting', + hookEventName: 'PreToolUse', + toolName: 'AskUserQuestion', + toolUseId: 'tool-question' + }) + const request = { ...escapeRequestFromSnapshot(server), baselineUpdatedAt: 1 } + + expect(server.inferInterrupt(request)).toBe(false) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ state: 'waiting', toolName: 'AskUserQuestion' }) + ]) + }) + + it.each([ + ['plain-escape', 'Bash'], + ['ctrl-c', 'AskUserQuestion'] + ] as const)('does not clear a Claude wait from %s on %s', (intent, toolName) => { + const server = new AgentHookServer() + ingestClaudeStatus(server, { + state: 'waiting', + hookEventName: 'PermissionRequest', + toolName, + toolUseId: 'tool-wait' + }) + + expect(server.inferInterrupt({ ...escapeRequestFromSnapshot(server), intent })).toBe(false) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ state: 'waiting', toolName }) + ]) + }) +}) + describe('inferQuestionAnswered', () => { it('clears an AskUserQuestion wait when the submit keystroke is reported', () => { const server = new AgentHookServer() diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index dd7c9dcd3ff..1eeac826140 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -742,6 +742,14 @@ export class AgentHookServer { ) { return false } + const dismissesClaudeQuestion = + agentType === 'claude' && + request.intent === 'plain-escape' && + payload.state === 'waiting' && + isAskUserQuestionTool(payload.toolName) + if (dismissesClaudeQuestion) { + return this.inferQuestionAnswered(request) + } // Why: inference is a fallback for a missing final hook; a strict baseline match keeps a delayed timer from clobbering any newer hook. if ( payload.state !== 'working' || @@ -797,8 +805,7 @@ export class AgentHookServer { return true } - /** Guarded fallback for a hook Claude never sends: answering AskUserQuestion produces no event, so re-validate the - * renderer's baseline against the cached status (a racing real hook wins) and synthesize the post-answer state. */ + /** Guarded fallback for the hook Claude omits after answering or dismissing AskUserQuestion. */ inferQuestionAnswered(request: AgentQuestionAnsweredInferenceRequest): boolean { if (!isValidPaneKey(request.paneKey)) { return false @@ -843,7 +850,7 @@ export class AgentHookServer { ...(payload.subagents ? { subagents: payload.subagents } : {}) } }) - console.debug('[agent-hooks] inferred answered question status', { + console.debug('[agent-hooks] inferred resolved question status', { paneKey: inferred.paneKey, state: inferred.payload.state }) 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 fc4eb329c8c..83252ccf93c 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 @@ -101,6 +101,51 @@ describe('agent interrupt inference', () => { } ) + it('reports Escape while Claude is waiting on AskUserQuestion', () => { + vi.useFakeTimers() + const inferInterrupt = vi.fn() + const tracker = createAgentInterruptInference({ + paneKey: PANE_KEY, + getStatusEntry: () => + makeEntry({ state: 'waiting', agentType: 'claude', toolName: 'AskUserQuestion' }), + inferInterrupt, + now: () => 1_100 + }) + + tracker.observeInputIntent('plain-escape') + vi.advanceTimersByTime(500) + + expect(inferInterrupt).toHaveBeenCalledWith({ + paneKey: PANE_KEY, + baselineUpdatedAt: 1_000, + baselineStateStartedAt: 900, + baselinePrompt: 'write tests', + baselineAgentType: 'claude', + intent: 'plain-escape' + }) + tracker.dispose() + }) + + it.each([ + ['ctrl-c', 'AskUserQuestion'], + ['plain-escape', 'Bash'] + ] as const)('does not dismiss a Claude wait from %s on %s', (intent, toolName) => { + vi.useFakeTimers() + const inferInterrupt = vi.fn() + const tracker = createAgentInterruptInference({ + paneKey: PANE_KEY, + getStatusEntry: () => makeEntry({ state: 'waiting', agentType: 'claude', toolName }), + inferInterrupt, + now: () => 1_100 + }) + + tracker.observeInputIntent(intent) + vi.advanceTimersByTime(500) + + expect(inferInterrupt).not.toHaveBeenCalled() + tracker.dispose() + }) + it('emits when the working row has no agent type', () => { vi.useFakeTimers() let entry: AgentStatusEntry | undefined = makeEntry({ agentType: undefined }) 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 801afd2440c..d4ff64f6662 100644 --- a/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts +++ b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts @@ -7,6 +7,7 @@ import { type AgentInterruptInferenceRequest, type AgentInterruptInputIntent } from '../../../../shared/agent-interrupt-intent' +import { isAskUserQuestionTool } from '../../../../shared/agent-question-answered-intent' import { isExplicitAgentStatusFresh } from '@/lib/agent-status' export type AgentInterruptInference = { @@ -56,6 +57,16 @@ function shouldIgnoreInterruptIntent( return agentType === 'droid' && intent === 'ctrl-c' } +function canInferInterrupt(entry: AgentStatusEntry, intent: AgentInterruptInputIntent): boolean { + return ( + entry.state === 'working' || + (intent === 'plain-escape' && + entry.state === 'waiting' && + entry.agentType === 'claude' && + isAskUserQuestionTool(entry.toolName)) + ) +} + function isSameTurnBaseline( left: CapturedInterruptBaseline, right: CapturedInterruptBaseline @@ -133,7 +144,7 @@ export function createAgentInterruptInference({ ): CapturedInterruptBaseline | null => { const agentType = entry.agentType if ( - entry.state !== 'working' || + !canInferInterrupt(entry, intent) || !isExplicitAgentStatusFresh(entry, now(), AGENT_STATUS_STALE_AFTER_MS) ) { return null @@ -158,7 +169,7 @@ export function createAgentInterruptInference({ const entry = getStatusEntry() if ( entry && - (entry.state !== 'working' || + (!canInferInterrupt(entry, baseline.intent) || entry.agentType !== baseline.agentType || entry.prompt !== baseline.prompt || entry.updatedAt !== baseline.updatedAt ||