From 2e3af8b1aa61819693cc08583bf642db96a68cd8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:23:40 -0700 Subject: [PATCH] Preserve AskUserQuestion waits during parallel tool completions (#13714) * fix(agent-hooks): preserve parallel question waits * fix(agent-hooks): preserve child question waits (cherry picked from commit c065a2d7e107f619b3459ffcbf8d5ed5837b6d6b) --- src/shared/agent-hook-listener.test.ts | 106 +++++++++++++++++++++++++ src/shared/agent-hook-listener.ts | 47 ++++++++--- 2 files changed, 144 insertions(+), 9 deletions(-) diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index edab2cb88f6..336066b834a 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -351,6 +351,90 @@ describe('shared agent-hook-listener', () => { expect(next?.payload.interactivePrompt).toBeUndefined() }) + it('keeps AskUserQuestion visible through a late parallel sibling completion', () => { + const questions = { questions: [{ question: 'Pick', options: ['a', 'b'] }] } + const question = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PreToolUse', + tool_name: 'AskUserQuestion', + tool_use_id: 'tool-question', + tool_input: questions + } + }, + 'production' + ) + normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PermissionRequest', + tool_name: 'AskUserQuestion', + tool_input: questions + } + }, + 'production' + ) + const siblingCompletion = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_use_id: 'tool-sibling', + tool_input: { command: 'sleep 5' } + } + }, + 'production' + ) + + expect(siblingCompletion?.payload).toMatchObject({ + state: 'waiting', + toolName: 'AskUserQuestion', + interactivePrompt: question?.payload.interactivePrompt + }) + }) + + it('clears AskUserQuestion when its own PostToolUse arrives', () => { + normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PreToolUse', + tool_name: 'AskUserQuestion', + tool_use_id: 'tool-question', + tool_input: { questions: [{ question: 'Pick', options: ['a', 'b'] }] } + } + }, + 'production' + ) + const answered = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PostToolUse', + tool_name: 'AskUserQuestion', + tool_use_id: 'tool-question' + } + }, + 'production' + ) + + expect(answered?.payload.state).toBe('working') + expect(answered?.payload.interactivePrompt).toBeUndefined() + }) + it('does not re-assert the AskUserQuestion prompt on PostToolUse', () => { // The question was answered, so PostToolUse must clear the live card instead // of re-deriving the `{questions}` prompt from the carried tool input. @@ -3507,6 +3591,28 @@ describe('shared agent-hook-listener', () => { expect(childTool?.payload.toolName).toBe('AskUserQuestion') }) + it('keeps a child AskUserQuestion visible through its parallel sibling completion', () => { + const question = claudeEvent({ + hook_event_name: 'PreToolUse', + agent_id: 'a1', + tool_use_id: 'question-1', + tool_name: 'AskUserQuestion', + tool_input: { questions: [{ question: 'Pick', options: ['a', 'b'] }] } + }) + + const siblingCompletion = claudeEvent({ + hook_event_name: 'PostToolUse', + agent_id: 'a1', + tool_use_id: 'sibling-1', + tool_name: 'Bash', + tool_input: { command: 'sleep 5' } + }) + + expect(siblingCompletion?.payload.state).toBe('waiting') + expect(siblingCompletion?.payload.interactivePrompt).toBe(question?.payload.interactivePrompt) + expect(siblingCompletion?.payload.toolName).toBe('AskUserQuestion') + }) + it('preserves the interrupted flag across a gated working window', () => { claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'long job' }) claudeEvent({ diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 9e133aa683a..fa833ff0da5 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -146,6 +146,8 @@ export type ClaudeLeadTurnState = { interrupted?: true /** Subagent that induced the wait; only its next tool activity may clear it, so other children's churn can't dismiss a pending human-input card. */ waitingAgentId?: string + /** Tool call that owns the wait; late completions from parallel sibling tools must not dismiss its card. */ + waitingToolUseId?: string /** Lead state a child-induced wait displaced, restored when the wait clears; can't invent 'working' since the done-gate only downgrades done→working, never back. */ stateBeforeWait?: Pick } @@ -2614,7 +2616,7 @@ function normalizeClaudeSubagentLifecycleEvent( clearClaudePendingWaitForAgent(state, paneKey, (waitingAgentId) => waitingAgentId === agentId) } } - return buildClaudeChildDrivenStatusPayload(state, eventName, paneKey, hookPayload) + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload) } /** Sync the Claude lead-turn record when the SERVER infers an interrupt outside the hook stream (Ctrl+C with a missed Stop); else a later child lifecycle event resurrects the cancelled pane. */ @@ -2706,8 +2708,8 @@ export function clearClaudeAnsweredQuestionWait( return effectiveState === restored.state ? restored : { state: effectiveState } } -/** Re-emit the lead's cached state on child activity — gated up to 'working' while a child works — without touching the lead's tool/prompt caches, so a live card or permission wait survives child churn. */ -function buildClaudeChildDrivenStatusPayload( +/** Re-emit the cached lead state without touching its tool/prompt caches; child churn and parallel completions must not dismiss live cards. */ +function buildClaudeCachedLeadStatusPayload( state: HookListenerState, eventName: unknown, paneKey: string, @@ -2788,10 +2790,13 @@ function normalizeClaudeEvent( return null } - // Why: Claude's auto-allowed AskUserQuestion emits PreToolUse (not PermissionRequest; its Notification hook isn't registered) while blocked on a human answer. - // Treat that PreToolUse as waiting so the sidebar shows amber attention, not a spinner that decays to grey. Mirrors normalizeKimiEvent. - const isAskUserQuestion = - eventName === 'PreToolUse' && isAskUserQuestionTool(readString(hookPayload, 'tool_name')) + // Why: Claude normally emits PreToolUse while AskUserQuestion is blocked; newer builds can also report it as PermissionRequest. + // Treat the PreToolUse as waiting so the sidebar shows amber attention, not a spinner that decays to grey. Mirrors normalizeKimiEvent. + const eventToolName = readString(hookPayload, 'tool_name') + const isAskUserQuestionWait = + (eventName === 'PreToolUse' || eventName === 'PermissionRequest') && + isAskUserQuestionTool(eventToolName) + const isAskUserQuestion = eventName === 'PreToolUse' && isAskUserQuestionWait // Why: /compact can take minutes and does not emit Stop. PreCompact marks the pane busy; // PostCompact clears it so a finished compact cannot leave a sticky working spinner (#11352). const reportedStateName = @@ -2830,6 +2835,20 @@ function normalizeClaudeEvent( state.claudeActiveSessionCronPaneKeys.delete(paneKey) } + const eventToolUseId = readFirstString(hookPayload, ['tool_use_id', 'toolUseId']) + const previousTool = state.lastToolByPaneKey.get(paneKey) + const isParallelSiblingCompletionDuringQuestion = + eventAgentId === undefined && + previousLead?.state === 'waiting' && + isAskUserQuestionTool(previousTool?.toolName) && + (eventName === 'PostToolUse' || eventName === 'PostToolUseFailure') && + previousLead.waitingToolUseId !== undefined && + eventToolUseId !== undefined && + eventToolUseId !== previousLead.waitingToolUseId + if (isParallelSiblingCompletionDuringQuestion) { + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload) + } + // Why: subagent/teammate events carry `agent_id` (lead's don't); child tool activity keeps its row live but must not become the lead's state or overwrite its tool/prompt caches (a live card would vanish). // Two exceptions take the full path below: waiting-inducing events (a child needs human attention on this pane) and the blocked child's own next tool event (approval granted — clear the wait as for the lead). const isWaitingInducing = reportedStateName === 'waiting' @@ -2851,7 +2870,15 @@ function normalizeClaudeEvent( if (subagentOriginId) { const lead = state.claudeLeadStateByPaneKey.get(paneKey) if (lead?.state !== 'waiting' || lead.waitingAgentId !== subagentOriginId) { - return buildClaudeChildDrivenStatusPayload(state, eventName, paneKey, hookPayload) + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload) + } + const isParallelSiblingCompletionDuringChildQuestion = + (eventName === 'PostToolUse' || eventName === 'PostToolUseFailure') && + lead.waitingToolUseId !== undefined && + eventToolUseId !== undefined && + eventToolUseId !== lead.waitingToolUseId + if (isParallelSiblingCompletionDuringChildQuestion) { + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload) } // Why: approval granted — update the tool snapshot (drop the pending card) as the lead's own next tool event would. // Restore the stashed lead state, not this child's 'working': the lead may already be done, and the done-gate never upgrades working back to done once the roster drains. @@ -2866,7 +2893,7 @@ function normalizeClaudeEvent( // Why: lead events never carry agent_id; even a child missed by lifecycle tracking cannot own the lead turn or its background-work evidence. if (eventAgentId && !isWaitingInducing) { - return buildClaudeChildDrivenStatusPayload(state, eventName, paneKey, hookPayload) + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload) } if (isTurnBoundary && eventAgentId === undefined) { @@ -2891,10 +2918,12 @@ function normalizeClaudeEvent( ...(previousLead.interrupted ? { interrupted: true as const } : {}) } : undefined + const waitingToolUseId = eventToolUseId ?? previousLead?.waitingToolUseId state.claudeLeadStateByPaneKey.set(paneKey, { state: reportedStateName, ...(interrupted ? { interrupted } : {}), ...(isWaitingInducing && eventAgentId ? { waitingAgentId: eventAgentId } : {}), + ...(isAskUserQuestionWait && waitingToolUseId !== undefined ? { waitingToolUseId } : {}), ...(stateBeforeWait ? { stateBeforeWait } : {}) })