Files
orca/src/shared/native-chat-ask.ts
T
Brennan Benson c3ddc0d5df fix(mobile): keep native chat ask dismissals tab-scoped and gated (STA-3333) (#12497)
* fix(mobile): keep native chat ask dismissals tab-scoped and gated

Dismissal state lived in the chat view subtree, which unmounts on a
chat<->terminal toggle, so an answered ask card came back on return. It
also had no tab scope and no waiting/blocked gate.

- move dismissal into the controller, keyed per session tab
- gate ask cards on waiting/blocked like the permission path already is,
  and retire a dismissal off the ungated detected prompt so a working/done
  status can't be mistaken for the prompt clearing
- ignore a dismissal that settles after its prompt cleared or was replaced

Refs STA-3333.

* fix(mobile): keep an ask dismissal through the transcript re-subscribe

A view toggle or tab switch re-subscribes the native-chat transcript, and
useMobileNativeChatSession withholds `messages` until that read settles. A
transcript-derived ask therefore reads as null while the chat surface is
already visible, so the reset effect took it as "the agent moved on" and
retired a live dismissal — the answered card came back, which is the bug
the off-chat guard was meant to close.

Treat an unobserved null as unobserved: `observing` now also requires the
read to have settled. A prompt that is already detected stays observable on
its own, so a status-derived ask still registers on first paint and an
answer taken during that first load is still accepted.

* fix(mobile): keep the transcript-derived ask outside the paused gate

A hook row idle past AGENT_STATUS_STALE_AFTER_MS (30m) projects to `done`
with no interactivePrompt, so the transcript fallback is the only source
left for a still-pending question. Gating it behind waiting/blocked made
that question unanswerable from mobile. Only the sticky status payload
needs the gate; `extractPendingAsk` clears itself on the tool result.

Also pins the load-window clause in the ask-observability guard, which
was behaviourally load-bearing but killed no test.

* fix(mobile): treat a never-read transcript as unobserved, not as "no ask"

The ask-observability guard only excused `transcriptLoading`, which is true
for an in-flight read alone. useMobileNativeChatSession also withholds
`messages` when the client is gone ('idle') or the tab has not reported a
provider session yet ('waiting-session') — both leave the flag false over an
empty list that was never read. The derived prompt then read as null, the
reset effect took that as "the agent moved on", and a live dismissal was
retired; when the read landed with the question still pending the answered
card came back — the resurfacing bug this guard exists to close.

Gate on the read having actually settled instead. 'error' still counts: it
keeps the last successful read in `messages`, so a prompt that clears under
it is real evidence, unlike a list that was never populated.

Also locks three guards that killed no test: the sticky-status suppression
of the transcript fallback (which is what makes the new paused gate hold in
the post-answer window), the reset effect's identity bail-out, and showAsk's
empty-prompt case. The transcript stand-in now derives `transcriptLoading`
from `status` the way the real hook couples them, so these tests can only
express states the session hook can reach.

Refs STA-3333.

* test(mobile): pin the ask dismissal's tab scope and ungated retirement input

Both wirings were unpinned: swapping `scopeKey` to a constant or feeding the
gated `ask` in as `detectedAsk` left the whole mobile suite green.

* fix(mobile): require a landed read before an errored transcript retires a dismissal

`status === 'error'` was treated as settled on the claim that an error keeps
the last successful read in `messages`. That only holds for an error that lands
on top of an earlier read. The host forwards an initial-drain failure as an
error frame carrying an EMPTY list (transcript-watch-error.test.ts), the mobile
frame applier checks `frame.error` before the messages array so those rows are
discarded, and the session hook's error path never calls `setMessages` — so a
first-read error leaves `messages` at the `[]` the identity-change effect wrote.

That frame is also not terminal: the watcher keeps `initialDrain` true and a
real snapshot follows once the read recovers. So a re-subscribe whose first
read errors made the never-populated list read as "no ask", retired the live
dismissal, and the recovered snapshot brought the answered card back over the
composer — the exact resurfacing this guard exists to close, and most likely on
remote/SSH transcript reads.

Require rows for the error case. Rows can only be present once a read landed,
so the predicate is never wrong in the resurfacing direction; it only declines
to retire a dismissal when the transcript was never observed.

Also drop the dismiss hook's `detectedAsk = ask` default and make both prompts
required. That default silently fed the gated prompt in as the detected one,
which is the pre-fix behavior: a paused-out card would read as "prompt gone"
and retire the dismissal. tsc now enforces the ungated payload at every call
site instead of leaving a trap for the next caller.

* fix(mobile): scope the ask dismissal to the provider session, not the tab

A restart, /clear, or resume swaps the provider session inside one tab. The
next session's first question is often byte-identical, so a tab-keyed dismissal
hid the live card and left the turn blocked with nothing to act on.

* chore: restore upstream formatting
2026-08-04 19:34:45 -07:00

301 lines
11 KiB
TypeScript

import type {
AskOption,
AskPrompt,
AskQuestion,
InteractiveQuestionParser
} from './native-chat-ask-types'
import { isInterruptedStatusMessage, type NativeChatMessage } from './native-chat-types'
export type { AskOption, AskPrompt, AskQuestion, InteractiveQuestionParser }
const QUESTION_TOOL_PARSERS = new Map<string, InteractiveQuestionParser>()
/** Stable cross-platform identity for one canonical question prompt. */
export function nativeChatAskDismissKey(prompt: AskPrompt | null): string | null {
return prompt ? `question:${JSON.stringify(prompt.questions)}` : null
}
export function registerQuestionTool(toolName: string, parser: InteractiveQuestionParser): void {
QUESTION_TOOL_PARSERS.set(toolName, parser)
}
function parseQuestionsShape(input: unknown): AskPrompt | null {
if (!input || typeof input !== 'object') {
return null
}
const rawQuestions = (input as { questions?: unknown }).questions
if (!Array.isArray(rawQuestions) || rawQuestions.length === 0) {
return null
}
const questions: AskQuestion[] = []
for (const raw of rawQuestions) {
if (!raw || typeof raw !== 'object') {
continue
}
const question = raw as Record<string, unknown>
const text = typeof question.question === 'string' ? question.question : ''
const options = parseOptions(question.options)
if (text || options.length > 0) {
questions.push({
question: text,
header: typeof question.header === 'string' ? question.header : undefined,
multiSelect: question.multiSelect === true,
options
})
}
}
return questions.length > 0 ? { questions } : null
}
function parseOptions(raw: unknown): AskOption[] {
if (!Array.isArray(raw)) {
return []
}
return raw
.map((option): AskOption | null => {
if (typeof option === 'string') {
return { label: option }
}
if (
option &&
typeof option === 'object' &&
typeof (option as { label?: unknown }).label === 'string'
) {
const value = option as { label: string; description?: unknown }
return {
label: value.label,
description: typeof value.description === 'string' ? value.description : undefined
}
}
return null
})
.filter((option): option is AskOption => option !== null)
}
for (const name of ['AskUserQuestion', 'ask_user_question', 'askUserQuestion']) {
QUESTION_TOOL_PARSERS.set(name, parseQuestionsShape)
}
function parseToolInput(toolName: string | undefined, input: unknown): AskPrompt | null {
const parser = toolName ? QUESTION_TOOL_PARSERS.get(toolName) : undefined
return (parser ? parser(input) : null) ?? parseQuestionsShape(input)
}
export function parseAskFromStatus(
interactivePrompt: string | undefined | null,
toolName?: string
): AskPrompt | null {
if (!interactivePrompt) {
return null
}
try {
return parseToolInput(toolName, JSON.parse(interactivePrompt))
} catch {
return null
}
}
/** Resolve the newest question tool that has not received its FIFO tool result.
* Transcript replay parses each tool-call through the same registered-parser +
* canonical-shape fallback as live status, so a question tool that rendered
* live cannot vanish from pending state after a reconnect/replay. */
export function extractPendingAsk(messages: readonly NativeChatMessage[]): AskPrompt | null {
let pending: AskPrompt | null = null
const outstanding: (AskPrompt | null)[] = []
for (const message of messages) {
// A new user turn (or an interrupt row) ends the turn that owns whatever
// calls are still in flight: their results never arrive, and `tool_use_id`
// is dropped at decode time so those orphans can never be matched by id.
// Without this reset one orphan shifts the result FIFO for the rest of the
// transcript and strands an answered ask as a permanent card over the
// composer (#11761). Claude's tool-result turns decode as role 'tool'.
if (message.role === 'user' || isInterruptedStatusMessage(message)) {
outstanding.length = 0
pending = null
}
for (const block of message.blocks) {
if (block.type === 'tool-call') {
const parsed = parseToolInput(block.name, block.input)
if (parsed) {
pending = parsed
}
outstanding.push(parsed)
} else if (block.type === 'tool-result' && outstanding.length > 0) {
const resolved = outstanding.shift()
if (resolved && resolved === pending) {
pending = null
}
}
}
}
return pending
}
/** One question's chosen answer, normalized for delivery: the selected option
* indices (in option order) plus any free-text "other" answer. Index-based (not
* label text) so the answer can be delivered by the selector's stable option
* number — see `buildAskAnswerKeys`. */
export type AskAnswerSelection = { indices: number[]; other?: string }
/** A single keystroke group to write to the agent PTY. `raw` bytes (option
* numbers, Enter, arrows) are written verbatim as keystrokes; `text` is a
* free-text answer the caller runs through its paste sanitizer before writing. */
export type AskAnswerKeyGroup = { raw: string } | { text: string }
/** True when this question is answered (a picked option or typed free text). */
function isAnswered(sel: AskAnswerSelection | undefined): boolean {
return (sel?.indices.length ?? 0) > 0 || (sel?.other ?? '').trim().length > 0
}
/** The picked labels + trimmed free text for one question, in option order. */
function answerLabels(question: AskQuestion, sel: AskAnswerSelection | undefined): string[] {
const labels = (sel?.indices ?? [])
.map((i) => question.options[i]?.label ?? '')
.filter((l) => l.length > 0)
const other = (sel?.other ?? '').trim()
return other ? [...labels, other] : labels
}
/** Build the human-readable answer text: one line per question, in question
* order, each the selected label(s) + free text joined by ", ". Empty answers
* stay empty lines so N lines always == N questions. Used for agents whose
* question tool commits a pasted answer (not Claude's arrow-navigate selector). */
export function formatAskAnswer(prompt: AskPrompt, selections: AskAnswerSelection[]): string {
return prompt.questions.map((q, i) => answerLabels(q, selections[i]).join(', ')).join('\n')
}
// Claude's AskUserQuestion is an arrow-navigate selector: a bare Enter commits
// the HIGHLIGHTED default (the first option), and pasted label text does not move
// the highlight — so answering by label silently delivered every non-first pick
// as the first option (STA-1860). Instead we drive the selector by each option's
// stable 1-based number (which matches the card's badge), the marker it commits
// on. Right-arrow steps to the next question / the Submit tab. Verified live
// against Claude Code's TUI; groups are written spaced apart (see the senders)
// because a navigation keystroke batched with Enter commits before the selector
// has applied it.
const ASK_ENTER = '\r'
const ASK_NEXT_TAB = '\x1b[C'
const ASK_PREVIOUS_ROW = '\x1b[A'
const ASK_NEXT_ROW = '\x1b[B'
const ASK_NOTES = '\t'
/** Build the ordered keystroke groups that answer a Claude Code AskUserQuestion.
* Each group is written a step apart so the selector applies it before the next.
*
* - single-select pick → the option number (selects AND commits; in a
* multi-question prompt it auto-advances to the next question)
* - free-text answer → the "Type something" row number, the text, then Enter
* - multi-select → each option number TOGGLES its checkbox, then a step
* to the Submit tab
* - a multi-question prompt (and a lone multi-select) finishes on a Submit
* confirmation, so it ends with one Enter
*
* (Option counts are ≤ the tool's cap of a few, so single-digit numbers always
* address every row.) */
export function buildAskAnswerKeys(
prompt: AskPrompt,
selections: AskAnswerSelection[]
): AskAnswerKeyGroup[] {
const questions = prompt.questions
const multiQuestion = questions.length > 1
const groups: AskAnswerKeyGroup[] = []
questions.forEach((q, qi) => {
const sel = selections[qi]
const other = (sel?.other ?? '').trim()
const typeSomething = String(q.options.length + 1)
if (q.multiSelect) {
for (const i of sel?.indices ?? []) {
groups.push({ raw: String(i + 1) })
}
if (other) {
groups.push({ raw: typeSomething }, { text: other }, { raw: ASK_ENTER })
}
// A multi-select never auto-advances; step to the next tab (the Submit tab
// when this is the last question).
groups.push({ raw: ASK_NEXT_TAB })
} else if (other) {
// Single-select can only carry one value, so route any answer that
// includes free text through the "Type something" row as one string.
groups.push(
{ raw: typeSomething },
{ text: answerLabels(q, sel).join(', ') },
{ raw: ASK_ENTER }
)
} else if ((sel?.indices.length ?? 0) > 0) {
groups.push({ raw: String(sel!.indices[0]! + 1) })
} else if (multiQuestion) {
// Unanswered question in a multi-question prompt: step past it.
groups.push({ raw: ASK_NEXT_TAB })
}
})
const endsOnSubmitTab =
multiQuestion || (questions.length === 1 && questions[0]!.multiSelect === true)
if (endsOnSubmitTab && groups.length > 0) {
groups.push({ raw: ASK_ENTER })
}
return groups
}
/** Build keystrokes for Codex's request_user_input overlay.
*
* Unlike Claude, Codex submits on the final option digit and attaches free text
* as notes to the highlighted row. The overlay starts on the first row, so note
* answers move to the target without committing, open notes with Tab, then
* submit with Enter. */
export function buildCodexAskAnswerKeys(
prompt: AskPrompt,
selections: AskAnswerSelection[]
): AskAnswerKeyGroup[] {
const groups: AskAnswerKeyGroup[] = []
let hasUnanswered = false
prompt.questions.forEach((question, questionIndex) => {
const selection = selections[questionIndex]
const selectedIndex = selection?.indices[0]
const note = (selection?.other ?? '').trim()
if (note) {
const targetIndex = selectedIndex ?? question.options.length
const rowCount = question.options.length + 1
const nextSteps = targetIndex
const previousSteps = rowCount - targetIndex
const usePrevious = previousSteps < nextSteps
const navigationKey = usePrevious ? ASK_PREVIOUS_ROW : ASK_NEXT_ROW
const navigationSteps = usePrevious ? previousSteps : nextSteps
for (let index = 0; index < navigationSteps; index += 1) {
groups.push({ raw: navigationKey })
}
groups.push({ raw: ASK_NOTES }, { text: note }, { raw: ASK_ENTER })
return
}
if (selectedIndex !== undefined) {
groups.push({ raw: String(selectedIndex + 1) })
return
}
hasUnanswered = true
groups.push({ raw: '\x7f' })
if (questionIndex < prompt.questions.length - 1) {
groups.push({ raw: ASK_NEXT_TAB })
} else {
groups.push({ raw: ASK_ENTER })
}
})
// Codex opens a confirmation after the last question when any were skipped;
// Proceed is highlighted by default, so one Enter submits the partial answer.
if (hasUnanswered) {
groups.push({ raw: ASK_ENTER })
}
return groups
}
/** Whether any question in `selections` carries an answer worth submitting. */
export function hasAskAnswer(prompt: AskPrompt, selections: AskAnswerSelection[]): boolean {
return prompt.questions.some((_, i) => isAnswered(selections[i]))
}