Files
orca/src/shared/native-chat-ask.ts
T
Brennan Benson 9738dc6c08 Bring main back under the line limits (main lint is red) (#31729)
* fix(browser): bring the client-hosted page pane back under the 400-line limit

Move the client-hosted guest's six event-listener add/remove calls into
bindBrowserClientPageGuestEvents beside the guest load-failure handler,
so ClientHostedBrowserPagePane.tsx drops from 404 to 395 counted lines.
No behavior change.

* test(browser): pin the client page guest event binder's bind and unbind contract

* fix(native-chat): bring the question module back under the 300-line limit

Move answer encoding (pasted answer text and the Claude and Codex selector
keystrokes) into native-chat-ask-answer.ts and re-export it, so importers are
unchanged.
2026-10-21 18:01:39 -07:00

228 lines
7.7 KiB
TypeScript

import type {
AskOption,
AskPrompt,
AskQuestion,
InteractiveQuestionParser
} from './native-chat-ask-types'
import { isInterruptedStatusMessage, type NativeChatMessage } from './native-chat-types'
import {
CODEX_ASYNC_QUESTION_TOOL,
parseCodexAsyncQuestionsInput
} from './codex-async-question-input'
export type { AskOption, AskPrompt, AskQuestion, InteractiveQuestionParser }
const QUESTION_TOOL_PARSERS = new Map<string, InteractiveQuestionParser>()
export { nativeChatAskDismissKey } from './native-chat-ask-dismiss-key'
export function registerQuestionTool(toolName: string, parser: InteractiveQuestionParser): void {
QUESTION_TOOL_PARSERS.set(toolName, parser)
}
function parseCanonicalQuestionsInput(input: unknown): AskPrompt | null {
if (!input || typeof input !== 'object' || !('questions' in input)) {
return null
}
const rawQuestions = input.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 text = 'question' in raw && typeof raw.question === 'string' ? raw.question : ''
const options = parseOptions('options' in raw ? raw.options : undefined)
if (text || options.length > 0) {
questions.push({
question: text,
header: 'header' in raw && typeof raw.header === 'string' ? raw.header : undefined,
multiSelect:
'multiSelect' in raw
? raw.multiSelect === true
: ('multiple' in raw && raw.multiple === true) ||
('multi' in raw && raw.multi === 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' &&
'label' in option &&
typeof option.label === 'string'
) {
return {
label: option.label,
description:
'description' in option && typeof option.description === 'string'
? option.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, parseCanonicalQuestionsInput)
}
QUESTION_TOOL_PARSERS.set(CODEX_ASYNC_QUESTION_TOOL, parseCodexAsyncQuestionsInput)
function parseToolInput(toolName: string | undefined, input: unknown): AskPrompt | null {
const parser = toolName ? QUESTION_TOOL_PARSERS.get(toolName) : undefined
return (parser ? parser(input) : null) ?? parseCanonicalQuestionsInput(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
}
}
/** Parse a question tool call's own input, through the same registered-parser
* dispatch live status uses. Codex delivers arguments as a JSON string, so a
* string input is decoded rather than treated as prose. */
export function parseAskFromToolInput(
toolName: string | undefined,
input: unknown
): AskPrompt | null {
return typeof input === 'string'
? parseAskFromStatus(input, toolName)
: parseToolInput(toolName, input)
}
/** 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 {
const scan = scanPendingAsks(messages)
return scan.blocking ?? scan.replyAsMessage
}
/** A structured chat's pending question from its messages: blocking ones arrive as journal
* prompts, so only those answered by an ordinary message are left to read here. */
export function extractPendingMessageReplyAsk(
messages: readonly NativeChatMessage[]
): AskPrompt | null {
return scanPendingAsks(messages).replyAsMessage
}
/** A user row the agent received; a queued, unsent or withdrawn send answers nothing yet. */
function reachedAgent(message: NativeChatMessage): boolean {
return (
message.queued !== true &&
message.unsent !== true &&
message.stoppedBeforeStart !== true &&
message.awaitsRetry !== true &&
message.sentWhileStopping !== true
)
}
function scanPendingAsks(messages: readonly NativeChatMessage[]): {
blocking: AskPrompt | null
replyAsMessage: AskPrompt | null
} {
let pending: AskPrompt | null = null
// The FIFO is two counters, not a queue: a result only needs to know how far the
// call that produced `pending` sits from the head, so nothing is retained per call.
let outstanding = 0
let pendingDepth = -1
// Each async question stays open past its immediate result until the agent receives a message.
let asyncQuestions: AskQuestion[] = []
const asyncCallIds = new Set<string>()
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'.
const interrupted = isInterruptedStatusMessage(message)
if (message.role === 'user' || interrupted) {
outstanding = 0
pendingDepth = -1
pending = null
if (interrupted || reachedAgent(message)) {
asyncQuestions = []
asyncCallIds.clear()
}
}
for (const block of message.blocks) {
if (block.type === 'tool-call') {
const parsed = QUESTION_TOOL_PARSERS.has(block.name)
? parseAskFromToolInput(block.name, block.input)
: parseToolInput(block.name, block.input)
if (parsed?.replyAsMessage) {
if (block.callId === undefined || !asyncCallIds.has(block.callId)) {
asyncQuestions = [...asyncQuestions, ...parsed.questions]
}
if (block.callId !== undefined) {
asyncCallIds.add(block.callId)
}
} else if (parsed) {
pending = parsed
pendingDepth = outstanding
}
outstanding += 1
} else if (block.type === 'tool-result' && outstanding > 0) {
outstanding -= 1
if (pendingDepth === 0) {
pending = null
pendingDepth = -1
} else if (pendingDepth > 0) {
pendingDepth -= 1
}
}
}
}
return {
blocking: pending,
replyAsMessage:
asyncQuestions.length > 0 ? { questions: asyncQuestions, replyAsMessage: true } : null
}
}
/** Prefers live status and consults transcript history only after its read settles. */
export function resolveNativeChatAsk(args: {
liveAsk: AskPrompt | null
messages: readonly NativeChatMessage[]
transcriptSettled: boolean
}): AskPrompt | null {
return args.liveAsk ?? (args.transcriptSettled ? extractPendingAsk(args.messages) : null)
}
export {
buildAskAnswerKeys,
buildCodexAskAnswerKeys,
formatAskAnswer,
hasAskAnswer,
type AskAnswerKeyGroup,
type AskAnswerSelection
} from './native-chat-ask-answer'