Files
orca/src/shared/agent-question-answered-intent.ts
T
Brennan Benson 2cb5d4e149 fix(agent-status): clear answered Claude question waits at answer time (#9074)
* fix(agent-status): clear answered Claude question waits at answer time

An answered AskUserQuestion left the amber "waiting" indicator on sidebar
rows and tabs until the agent's next tool hook or turn end — unbounded
linger while the model thinks or streams after the answer (measured 17s
for a 1000-word reply, 44s for 3000 words).

Root cause is an event-shape change: newer Claude reports the
AskUserQuestion wait as PermissionRequest (not the PreToolUse shape #7852
special-cased), so the wait inherited real-permission stickiness and
shouldKeepClaudePermissionVisible swallowed the answer-time
PostToolUse(AskUserQuestion) working event — the identity match can never
succeed because the question's PermissionRequest carries no inheritable
tool_use_id. That silently undid #8311 for questions.

Two scoped changes, both keyed on the tool name rather than the hook
event name:

- Sticky permission hold now exempts AskUserQuestion waits, so the real
  answer-time hook (when Claude sends one) clears the wait as #8311
  intended.
- New guarded inference for the hook Claude may never send: the submit
  keystroke (Enter or digit quick-select) into a pane whose fresh status
  is a waiting AskUserQuestion synthesizes the post-answer state, exactly
  mirroring the existing interrupt inference (renderer baseline capture,
  main-process re-validation, listener lead-state sync so child-driven
  refreshes cannot resurrect the dismissed question).

Real permission waits (other tools) keep their sticky semantics; batched
input and pastes never match the submit classifier.

Verified live against a real claude CLI: waiting -> working within ~50ms
of both Enter and digit answers, question card dropped, unanswered
questions still hold amber, permission stickiness covered by tests.

* fix(agent-status): guard question answer inference

Keep multi-question, multi-select, and free-text selector interactions waiting until the full prompt is submitted. Wire native-chat answers into the same guarded inference only after every paced runtime write succeeds, with cancellation and delivery-failure coverage.

* chore(skills): refresh manifest for rc.2

* fix(agent-status): verify native chat answer delivery

* fix(agent-status): await verified question delivery

* fix(agent-status): pin native-chat answer baseline before delivery

The native-chat question-answered inference read the live pane status at
settle time (after the paced send + remote acceptance, which can span
seconds on SSH). If a replacement AskUserQuestion became current in that
window, the settle callback minted a fresh baseline from the new question
and the server cleared *its* wait — dismissing a question the user never
answered.

Capture the answered question's baseline before delivery and have the
inference getter return it, so the server re-validates against the pinned
baseline and rejects a changed status — the same capture-then-revalidate
contract the terminal keystroke path already uses. Also hoist the
shouldStepNativeChatAskAnswer predicate to a single evaluation.

Regression test swaps the live status between sendAnswer and settle and
asserts the answered question's baseline is used (fails against the prior
live-read getter).
2026-07-17 12:47:04 -07:00

84 lines
3.1 KiB
TypeScript

import type { AgentType } from './agent-status-types'
/** Baseline snapshot the renderer captured when it observed the submit
* keystroke. The main process re-validates every field against its own
* cached status so a racing real hook always wins over the inference. */
export type AgentQuestionAnsweredInferenceRequest = {
paneKey: string
baselineUpdatedAt: number
baselineStateStartedAt: number
baselinePrompt: string
baselineAgentType: AgentType | undefined
}
/** True for the AskUserQuestion tool across the casing variants different
* agents emit (`AskUserQuestion` / `ask_user_question` / `askUserQuestion`).
* Why: this is the structured "pick an option" prompt whose full input the
* clients render as a live card. */
export function isAskUserQuestionTool(toolName: string | undefined): boolean {
return toolName?.replaceAll(/[^a-z0-9]/gi, '').toLowerCase() === 'askuserquestion'
}
const QUESTION_ANSWER_ENTER_INPUTS: ReadonlySet<string> = new Set([
'\r',
'\n',
'\r\n',
'\x1b[13u',
'\x1b[13;1u'
])
const QUESTION_ANSWER_DIGIT_INPUTS: ReadonlySet<string> = new Set('123456789')
export function isPotentialQuestionAnsweredSubmitInput(data: string): boolean {
return QUESTION_ANSWER_ENTER_INPUTS.has(data) || QUESTION_ANSWER_DIGIT_INPUTS.has(data)
}
function readSingleSelectOptionCount(interactivePrompt: string | undefined): number | null {
if (!interactivePrompt) {
return null
}
try {
const parsed = JSON.parse(interactivePrompt) as { questions?: unknown }
if (!Array.isArray(parsed.questions) || parsed.questions.length !== 1) {
return -1
}
const [question] = parsed.questions as { multiSelect?: unknown; options?: unknown }[]
if (!question || question.multiSelect === true || !Array.isArray(question.options)) {
return -1
}
return question.options.length
} catch {
// Why: malformed JSON can be a length-capped multi-question payload. It is
// not equivalent to an older hook omitting tool input, so fail closed.
return -1
}
}
/** True only when one keystroke is enough to finish the whole prompt.
* Why: digits merely advance multi-question prompts and toggle multi-selects;
* the synthetic "Type something" row also opens an editor instead of
* submitting. Without the prompt-shape gate those partial choices clear the
* waiting indicator while Claude is still blocked on more input. */
export function isQuestionAnsweredSubmitInput(
data: string,
interactivePrompt: string | undefined
): boolean {
if (!isPotentialQuestionAnsweredSubmitInput(data)) {
return false
}
const optionCount = readSingleSelectOptionCount(interactivePrompt)
if (optionCount === -1) {
return false
}
if (QUESTION_ANSWER_ENTER_INPUTS.has(data)) {
// Older hook payloads can omit tool input; Enter remains the conservative
// fallback because it is the ordinary submit path for a question.
return true
}
if (optionCount === null) {
return false
}
// Claude adds a final "Type something" row after the declared options.
// Only declared option numbers complete a single-select immediately.
return Number(data) <= optionCount
}