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 c065a2d7e1)
This commit is contained in:
Brennan Benson
2026-08-10 23:15:09 -07:00
committed by Jinjing
parent 26650ce584
commit 2e3af8b1aa
2 changed files with 144 additions and 9 deletions
+106
View File
@@ -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({
+38 -9
View File
@@ -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<ClaudeLeadTurnState, 'state' | 'interrupted'>
}
@@ -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 } : {})
})