mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 08:03:33 +00:00
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:
@@ -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({
|
||||
|
||||
@@ -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 } : {})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user