Files
orca/src/shared/native-chat-ask.ts
T
a2896f5470 Support real OpenCode sessions in native Chat (#24647)
* Use bounded OpenCode context for vault session continuation

OpenCode database and synthetic row paths are not text transcripts. Use the
vault preview or captured pane context, preserving actual transcript paths
containing a hash and supporting both OpenCode lanes and Windows paths.

Adapted the intent of #11859 and extended it to actual installed v2 vault rows.

Co-authored-by: mrcha033 <mrcha033@users.noreply.github.com>

* Read real OpenCode sessions in terminal-backed native Chat

Reuse the bounded AI Vault SQLite worker for v1 and v2 session pages and live updates. Keep terminal input as the real execution path and pace OpenCode Stop through its two-Escape interrupt.

Co-authored-by: xodmd45-ctrl <xodmd45-ctrl@users.noreply.github.com>

* fix(opencode): publish approval cards for permission requests

* Send OpenCode native approval through its Enter selector

* Resolve mobile Chat readability for folder workspaces

* Bound OpenCode part batches and preserve v2 image attachments

* Prefer live migrated OpenCode sessions over legacy copies

* Consolidate mobile Chat eligibility test imports

* Consolidate OpenCode SQLite protocol type imports

* Update native chat settings contract for both OpenCode agents

* fix(native-chat): reconcile bounded OpenCode transcript reads

* fix(native-chat): dispatch OpenCode questions safely

* fix(native-chat): keep native discovery and transcript windows current

* feat(accounts): link standalone GLM Coding Plans (#24618)

* feat(accounts): link standalone GLM Coding Plans

Adapt the reviewed GLM accounts contribution to current main, retain Antigravity behavior, guard late credential results, expose storage protection, and redact quota errors.

Co-authored-by: Luchong <lu740528977@gmail.com>

* fix(accounts): retain GLM credential results during quota refresh

* fix(accounts): make GLM credential editing desktop-only

* fix(accounts): mirror the host GLM site in paired clients

* fix(accounts): report unknown GLM host details and split web settings tests

Apply the independently reviewed Accounts correction from697284a without the v2 adapter commits. Preserve the saved-key store and serialized write behavior.

* fix(zcode): ship required GLM account translation entries

* chore: record GLM reconciliation hook validation

* chore: validate installed GLM commit hooks

* test: complete GLM account fixtures and web API inventory

---------

Co-authored-by: Luchong <lu740528977@gmail.com>

* fix(native-chat): route transcript requests through shared SQLite worker

* fix(ci): prevent concurrent pnpm refresh during mobile typechecks (#24776)

* fix(ci): run mobile typechecks without concurrent dependency refresh

* test(ci): check effective Linux E2E package list

* test(ci): preserve the mobile production compiler barrier

---------

Co-authored-by: Orca Integration Recovery <orca-validation@invalid.example>

* test(terminal): restore the live fish fixture prerequisites (#24947)

A restored pane waits for the initial status replay before subscribing to
PTY output. This fixture never settled that replay, so fish printed its
mode-2031 arm before the renderer connected. Its PTY API also omitted the
reset-input listener required by the serializer, aborting attachment.

Settle and dispose the existing startup-snapshot registration and provide
the same reset-listener mock used by the other PTY tests. The real fish
child-stdin assertions and timeouts remain unchanged. No production change.

* fix(shortcuts): defer TUI editing chords in terminal-first mode (#24640)

Restack the original focused change onto current main, preserving every owned source and test blob and the merged CI contract and journal cleanup fixes.

Original-commit: f707cde14a
fix(shortcuts): defer TUI editing chords in terminal-first mode

Original-commit: 0c6348e49e
docs(shortcuts): describe deferred preview terminal chords

Original-commit: be62c1b6c6
Align worktree history shortcut metadata with terminal conflict policy

Restacked-from: be62c1b6c6
Restacked-onto: f7b1f9d8be

* Register supervised Qoder China and Qwen Code (#24616)

* Add Qoder session history and search with real CLI coverage

* Allow the real Qoder marker file to end with a newline

* Keep Qoder tool output out of history previews and search

* Keep Qoder search pages readable by older clients

* Verify persisted Qoder history after a real generated and resumed task

* Negotiate Qoder filters before searching an older execution host

* Combine search client imports for the CI plugin gate

* Keep the relay search oracle aligned with legacy agent filtering

* Register supervised Qoder China and Qwen lifecycle integration

* Cover Qoder China mobile assets and mixed-host resume gates

* Verify Qoder provider tags against the older released wire parser

* Verify China and Qwen keep independent Windows hook scripts

* Verify Qoder registrations against the installed older Windows release

* test(qoder): align search capability contracts and pin old-host fencing

* fix(qoder): rank exact picker identities and command aliases first

* test(qoder): preserve the regional CLI shared icon expectation

Keep the full bundled-asset and no-remote-image checks, with an explicit
shared-logo basename for Qoder China. The map also works with older
catalog type unions.

* fix(qoder): align China catalog entry with fallback order

---------

Co-authored-by: Orca Integration Recovery <orca-validation@invalid.example>

* Use the measured pnpm lookup policy automatically in hosted root CI (#24951)

* Select lookup automatically for the measured hosted root-install profile

* Record hosted automatic-mode cold cache publication proof

* fix(native-chat): keep OpenCode history usable at read limits

Continue past failed database probes while preserving discovery cancellation.
Verify rows displaced by a capped tail before deciding whether to replace history.
Represent oversized v1/v2 rows with the existing omission text and stable cursors.

* Continue Antigravity IDE and 2.0 history in new CLI conversations (#24692)

* feat(antigravity): bridge IDE history into new CLI conversations

* fix(antigravity): preserve fresh-launch model and environment for IDE references

* fix(antigravity): forward IDE history opt-in through desktop IPC

* fix(antigravity): rebuild remote IDE reference startup on its host

* fix(antigravity): register IDE continuation action labels

* fix(antigravity): confine IDE references and bound metadata reads

* fix(antigravity): localize IDE continuation badges

* Preserve scanner service cache assertions and refresh Antigravity opening metadata

* Preserve Antigravity opening joins and target folder runtime authority

* fix(opencode): retry timed-out SSH plugin updates (#24666)

Preserve bounded retry behavior and the current-main status-envelope fields.

Original-PR: #24124
Reviewed-source: 103144f9c5

Co-authored-by: Justas Brazauskas <brazauskasjustas@gmail.com>

* fix(opencode): keep Go credentials private and resolve backend keys (#24615)

Preserve the complete credential storage, migration, IPC, Settings and rate-limit refresh change alongside standalone GLM plans, current-main database diagnostics and the reviewed unknown-backend environment correction. Keep native discovery cancellation third and selected environment fourth.

Original-topic-commit: 7903f1cddb
Original-topic-commit: 588117b5cf
Original-topic-commit: dedd4f8c86
Original-topic-commit: 6645dae104
Original-topic-commit: a25b80c02c1af7830b0e6a65e72d965b3ad98276
Restacked-from: a25b80c02c1af7830b0e6a65e72d965b3ad98276
Restacked-onto: b032867021

Co-authored-by: kespineira <kespineira@users.noreply.github.com>
Co-authored-by: kevimux <kevimux@users.noreply.github.com>
Reported-by: pullfrog
Reviewed-full-source: 849fe093073f4c1606bd65d79a0c725d955d0d1f
Native-helper-source: 80dbe23237


Reviewed-full-current-source: 36acb57d44adb3d378c0289c8c15f7da0fda214c

---------

Co-authored-by: mrcha033 <mrcha033@users.noreply.github.com>
Co-authored-by: xodmd45-ctrl <xodmd45-ctrl@users.noreply.github.com>
Co-authored-by: Luchong <lu740528977@gmail.com>
Co-authored-by: Orca Integration Recovery <orca-validation@invalid.example>
Co-authored-by: Justas Brazauskas <brazauskasjustas@gmail.com>
2026-10-03 01:00:40 -07:00

335 lines
13 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 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,
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)
}
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 {
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
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 = 0
pendingDepth = -1
pending = null
}
for (const block of message.blocks) {
if (block.type === 'tool-call') {
const parsed = parseToolInput(block.name, block.input)
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 pending
}
/** 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)
}
/** 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]))
}