Files
orca/src/shared/runtime-terminal-contracts.ts
T
Brennan Benson 468e4e1167 fix(native-chat): a prompt card owns the chat input until its answer lands (terminal-backed chat, desktop and phone) (#25761)
* fix(native-chat): an answerable prompt card owns the chat input until its answer lands

* fix(mobile): a terminal chat's composer waits while its prompt card is up

* test(native-chat): type the prompt card fixtures without casts

* fix(native-chat): scope replies to acknowledged prompt occurrences

* fix(native-chat): preserve answer ordering and verified delivery

* test(native-chat): keep mock RPC client inside test boundary

* test(native-chat): place mock fixtures in the test-only scope

* Keep runtime comments within the module size limit

* test: preserve prompt delivery coverage in desktop CI

* Treat an older host's accepted write as delivered

A newer desktop or phone talking to a host that predates the write
settlement field read every accepted reply as "unconfirmed". Prompt cards
never dismissed, the phone showed "Response unconfirmed" on every tap and
ordinary chat messages were held as "Delivery unconfirmed".

The reader now uses writeSettlement when present and otherwise keeps the
host's whole-write accepted/refused verdict, exactly as before this branch.
Only prompt answers ask for provider settlement; ordinary callers
(follow-up delivery, paste drafts, option commands, composer sends) are
back on the original contract, so the legacy-handoff error class, its
flag, the sequence-only send helper and the mobile handoff hook are gone.

* Keep terminal-pane Escape on the plain accepted write

Every pane's Escape/Ctrl+C goes through pty:writeAccepted. This branch had
switched that IPC to wait for provider settlement, which dropped the
"remount this pane" signal for a daemon session awaiting recovery and could
stall later keystrokes behind a slow daemon acknowledgment.

pty:writeAccepted is back to its original local-only, synchronous write.
Prompt answers opt into settlement with requireWriteSettlement on the same
channel, and a settled refusal while the daemon recovers now sends the same
remount signal. Ordinary verified sends regain their original fallback write.

* Report a partly accepted local paste as unconfirmed

A settled local write split into chunks returned plain false when a later
chunk was refused after earlier ones were accepted. Callers read false as
"nothing was written", so chat showed "Message not sent" with a prefix
already in the agent's input. It now reports the write as unconfirmed,
the same verdict the paired host gives for a partial write.

* Hide the chat composer under a prompt card instead of unmounting it

When an approval or question card took the input region, the composer
unmounted. A message still waiting for its Enter was cancelled and its
bubble deleted after the draft had already been cleared, so the message
vanished without a notice; composer history was also wiped each time.

The composer now stays mounted but hidden while a card owns input, so its
state survives. A send that has not submitted yet is still stopped (its
Enter would answer the card), but its bubble stays with "Message not sent"
so the text is not lost. The composer ref is detached while hidden, so
root typing, paste and reveal focus never reach it.

* Keep an answered prompt hidden after the chat view remounts

The "answered" dismissal lived in component state. Toggling chat to
terminal and back, a PTY reconnect, or leaving the phone session and coming
back while the approved tool was still running brought the answered
approval back, and it then took over the input again.

Desktop now keeps the answered occurrence per pane outside the view;
phone keeps it per chat tab outside the controller. Both still retire it
when the pane observes the prompt clear or change, desktop also when the
tab retires, and both maps are size-bounded.

* Update the prompt-reply reliability gate for the review fixes

Older hosts' accepted answers now dismiss like acknowledged ones, the
composer stays mounted under a card, and answered prompts survive a view
remount. The gate's invariant, oracle, assertion list, new test files and
the two latest evidence runs now describe that contract.

* Let users hide a prompt card, keep Escape from denying, and gate only Send on the phone

The chat input could stay locked behind a card the host never closes (for
example after a Deny typed in the terminal), and Escape on a focused
approval card denied the tool even when the user meant to close a picker.

- A Hide control (chevron) on terminal approval and question cards, desktop
  and phone, hides that prompt occurrence and gives the input back. It writes
  nothing to the agent and uses the same per-occurrence dismissal as an
  acknowledged answer, so a new occurrence shows the card again.
- On desktop, Escape on a card now does the same Hide instead of Deny, and a
  card that appears while the user is typing no longer takes focus.
- On the phone, a card blocks only Send: typing, dictation and image attach
  keep working on the draft. The placeholder is back to the normal one.

* Fix two comments that still called older-host replies unconfirmed

Since an older host's accepted write now counts as delivered, the
requireWriteSettlement comment and the reliability gate's oracle said the
opposite of the code. Both now describe the current rule.

* Collapse prompt cards to a strip instead of hiding them, and close the round-2 gaps

Hide removed a card completely, so nothing on screen said a prompt was still
waiting, and several edges let the chat type into a live prompt.

- Collapse (the header chevron, or Escape on desktop) folds the card to a
  one-line strip above the composer; the strip's chevron expands it back.
  Collapsing writes nothing, frees the composer, and is disabled while an
  answer is still being written. Each pane or tab keeps the occurrence as
  answered or collapsed, so a remount restores the same view.
- Questions now carry the host wait's start like approvals, so an identical
  question in a new wait shows again (desktop and phone). A transcript-only
  prompt, which has no wait start, is dropped when the view stops observing
  it, and a transcript still loading no longer clears a dismissal.
- Desktop: while a card owns the input, the hidden composer cannot send or
  interrupt even if it still has keyboard focus, and the card takes focus in
  the same commit. A send the card retires no longer types Ctrl+U under it.
- Phone: an Ask hides the heuristic card read from the same waiting status,
  and the dismissal store is scoped by host, worktree and tab.

* Keep a collapsed card's partial answer, and scope its focus to its own pane

Collapsing a question card unmounted it, so expanding it again lost the
chosen step, selections and typed "Other" text; Escape typed in that text
field collapsed the card. A card arriving while the user typed in another
surface (sidebar, notes, a browser URL bar) also took the keyboard.

- The collapsed card now stays mounted but hidden (and inert on desktop)
  under its strip, on desktop and phone, so a partial answer survives
  collapse and expand. Escape inside the card's text field no longer
  collapses it. The question card shows the same focus ring as the approval
  card.
- A card takes focus only from inside its own pane (its hidden composer) or
  from the page body, never from a text field elsewhere.
- Desktop and phone share one dismissal store in src/shared, bounded by the
  existing scope-cache helper, which moves to src/shared with it.
- The card send imports the verified helper from its own module, and the
  phone files are split so each name matches its contents (header action,
  strip, lane selector).

* Return focus to the composer after a prompt card collapses

Since a collapsed card stays mounted, Escape or the chevron left keyboard
focus inside the now hidden, inert card. The composer's reveal-focus took
that as focus already in the pane and stood down, then the browser dropped
focus to the page body, so typed keys went nowhere.

Reveal-focus now treats focus inside a hidden or inert subtree as not in the
pane and focuses the composer. On the phone, collapsing a card also
dismisses the keyboard so a hidden reply field does not keep it.

* Keep the question card's collapse chevron beside its Cancel button

The question card header spread its three items with justify-between, which
put the new chevron in the middle of the header. The title now takes the
free space, as in the approval card, so the chevron sits next to Cancel at
the right edge.

* Run the prompt tests on the merged main

Main now runs Vitest under Bun, which resolves a long data: URL import as a
package name, so the SSH delivery test loads its bundled mobile module from a
temp file instead. The phone prompt harnesses mock the live line that main's
view now renders, and add Platform, which main's text-selection helper reads,
the same way main's own view tests do.
2026-10-06 10:57:58 -07:00

346 lines
9.9 KiB
TypeScript

import type {
AgentProviderSessionMetadata,
SleepingAgentLaunchConfig
} from './agent-session-resume'
import type { StartupCommandDelivery } from './codex-startup-delivery'
import type { ExecutionHostId } from './execution-host'
import type { PtyIncarnationId } from './pty-incarnation'
import type { RuntimeListingHostScope } from './runtime-listing-host-scope'
import type { RuntimeMobileSessionTabsResult } from './runtime-session-contracts'
import type { TabGroupLayoutNode } from './tab-types'
import type { TerminalExitCause } from './terminal-exit-cause'
import type { TerminalPaneLayoutNode } from './terminal-tab-types'
import type { TerminalAgent, TuiAgent } from './terminal-agent'
export type {
RuntimeTerminalSend,
RuntimeTerminalPromptStage,
RuntimeTerminalPromptDelivery
} from './runtime-terminal-send-contract'
export type RuntimeTerminalSummary = {
handle: string
ptyId: string | null
incarnationId?: string | null
orphaned?: boolean
/**
* Orphaned only: the pane the host last recorded for this PTY, which the renderer owning it can
* still hold even when its graph omitted that pane. Absent when none was recorded or the host
* predates the field.
*/
recordedPaneKey?: string
worktreeId: string
worktreePath: string
branch: string
tabId: string
leafId: string
title: string | null
connected: boolean
writable: boolean
lastOutputAt: number | null
preview: string
/** Host-resolved observed agent identity; absent when unknown. Does not imply launch support. */
agentIdentity?: TerminalAgent
/** Absent while running or when the host predates the field; never infer a clean finish. */
exitCause?: TerminalExitCause
/** Absent when the host predates the field or could not name the execution host. */
executionHostId?: ExecutionHostId
}
export type RuntimeTerminalVisualTerminalNode = {
type: 'terminal'
handle: string
tabId: string
leafId: string
title: string | null
connected: boolean
active: boolean
}
export type RuntimeTerminalVisualPaneNode =
| RuntimeTerminalVisualTerminalNode
| {
type: 'pane-split'
direction: Extract<TerminalPaneLayoutNode, { type: 'split' }>['direction']
first: RuntimeTerminalVisualPaneNode
second: RuntimeTerminalVisualPaneNode
}
export type RuntimeTerminalVisualTab = {
tabId: string
title: string | null
activeLeafId: string | null
panes: RuntimeTerminalVisualPaneNode
}
export type RuntimeTerminalVisualGroupNode = {
type: 'group'
groupId: string | null
activeTabId: string | null
tabs: RuntimeTerminalVisualTab[]
}
export type RuntimeTerminalVisualLayoutNode =
| RuntimeTerminalVisualGroupNode
| {
type: 'split'
direction: Extract<TabGroupLayoutNode, { type: 'split' }>['direction']
first: RuntimeTerminalVisualLayoutNode
second: RuntimeTerminalVisualLayoutNode
}
export type RuntimeTerminalVisualLayout = {
worktreeId: string
worktreePath: string
root: RuntimeTerminalVisualLayoutNode
}
/** The shared listing-scope shape, kept under its incumbent name for existing consumers. */
export type RuntimeTerminalListHostScope = RuntimeListingHostScope
export type RuntimeTerminalListResult = {
terminals: RuntimeTerminalSummary[]
visualLayouts?: RuntimeTerminalVisualLayout[]
topologyRevisions?: Record<string, number>
totalCount: number
truncated: boolean
/** Absent from hosts that predate the field; treat that scope as unverifiable. */
hostScope?: RuntimeTerminalListHostScope
}
export type RuntimeTerminalOrphanAdoptionClaim = {
terminal: string
ptyId: string
incarnationId: PtyIncarnationId
tabId: string
leafId: string
}
export type RuntimeTerminalOrphanTopologyTab = {
tabId: string
root: TerminalPaneLayoutNode
activeLeafId: string
expandedLeafId: string | null
}
export type RuntimeTerminalOrphanTopologyGroup = {
id: string
activeTabId: string
tabOrder: string[]
recentTabIds?: string[]
}
export type RuntimeTerminalOrphanTopology = {
tabs: RuntimeTerminalOrphanTopologyTab[]
groups: RuntimeTerminalOrphanTopologyGroup[]
groupLayout?: TabGroupLayoutNode
}
export type RuntimeTerminalOrphanAdoptionRequest = {
worktree: string
expectedTopologyRevision: number
claims: RuntimeTerminalOrphanAdoptionClaim[]
activeTabId?: string
activeGroupId?: string
topology?: RuntimeTerminalOrphanTopology
}
export type RuntimeTerminalOrphanAdoptionResult = {
adopted: boolean
topologyRevision: number
snapshot: RuntimeMobileSessionTabsResult
}
export type RuntimeWorktreeTerminalSleepResult = {
stopped: number
stoppedPtyIds: string[]
livePtyIds: string[]
} & (
| { postStopVerified: true; postStopFailure?: never; remainingLivePtyIds?: never }
| {
postStopVerified: false
postStopFailure: 'terminal_liveness_unavailable'
remainingLivePtyIds?: never
}
| {
postStopVerified: false
postStopFailure: 'terminal_worktree_sleep_still_live'
remainingLivePtyIds: string[]
}
)
export type RuntimeWorktreeTerminalCloseResult = {
closed: number
stopped: number
retiredSurfaces: true
ptyStopVerdict?: 'live' | 'unverifiable'
ptyStopReason?: string
}
export type RuntimeTerminalInteractiveWaitSource = 'hook' | 'prompt-text' | 'title'
export type RuntimeTerminalInteractiveWait = {
source: RuntimeTerminalInteractiveWaitSource
reason?: RuntimeTerminalWaitBlockedReason
since?: number
}
export type RuntimeTerminalShow = RuntimeTerminalSummary & {
paneRuntimeId: number
ptyId: string | null
rendererGraphEpoch: number
/** Null means evaluated with no wait; absent means not evaluated. */
agentWait?: RuntimeTerminalInteractiveWait | null
}
export type RuntimeTerminalState = 'running' | 'exited' | 'unknown'
export type RuntimeTerminalRead = {
handle: string
status: RuntimeTerminalState
tail: string[]
truncated: boolean
limited?: boolean
oldestCursor?: string
nextCursor: string | null
latestCursor?: string
returnedLineCount?: number
source?: 'stream' | 'screen' | 'screen-unavailable'
/** UI-only composer text, excluded from `tail`. */
draft?: string
}
export type RuntimeTerminalRename = {
handle: string
tabId: string
title: string | null
}
export type RuntimeTerminalAgentStatusState = 'working' | 'permission' | 'idle' | null
export type RuntimeTerminalAgentStatus = {
handle: string
isRunningAgent: boolean
status: RuntimeTerminalAgentStatusState
}
export type RuntimeTerminalPresentation = 'background' | 'focused'
type RuntimeTerminalCreateBaseRequestPayload = {
requestId: string
worktreeId?: string
afterTabId?: string
targetGroupId?: string
command?: string
cwd?: string
env?: Record<string, string>
envToDelete?: string[]
launchConfig?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
launchToken?: string
launchAgent?: TuiAgent
viewMode?: 'terminal' | 'chat'
startupCommandDelivery?: StartupCommandDelivery
title?: string
activate?: boolean
presentation?: RuntimeTerminalPresentation
surfaceOwner?: false
/** Windows shell the created tab spawns AS, instead of the host default. */
shellOverride?: string
}
export type RuntimeTerminalCreateRequestPayload =
| (RuntimeTerminalCreateBaseRequestPayload & { source?: undefined })
| (RuntimeTerminalCreateBaseRequestPayload & {
worktreeId: string
source: 'runtime-session'
})
export type RuntimeTerminalCreate = {
handle: string
/** Host-owned PTY incarnation used to fence remote identity observations. */
incarnationId?: string | null
tabId?: string
paneKey?: string | null
ptyId?: string | null
worktreeId: string
title: string | null
executionHostId?: ExecutionHostId
hostPlatform?: NodeJS.Platform
surface?: 'background' | 'visible'
warning?: string
agentSessionDisposition?: 'created' | 'adopted'
isReattach?: true
/** Spawn process identity for host-internal ownership proof. */
processId?: number
}
export type RuntimeTerminalSplit = {
handle: string
tabId: string
paneRuntimeId: number
// Why: paired callers need the host-created leaf identity to focus the exact pane.
leafId?: string
}
export type RuntimeTerminalResolvePane = {
handle: string
/** Host-owned PTY incarnation used to fence remote identity observations. */
incarnationId?: string | null
tabId: string
leafId: string
ptyId: string | null
connected?: boolean
worktreeId?: string
executionHostId?: ExecutionHostId
hostPlatform?: NodeJS.Platform
}
export type RuntimeTerminalFocus = {
handle: string
tabId: string
worktreeId: string
navigated?: boolean
}
export type RuntimeTerminalClose = {
handle: string
tabId: string
closeMode?: 'tab'
ptyKilled: boolean
ptyStopVerdict?: 'live' | 'unverifiable'
ptyStopReason?: string
/** The host durably recorded a kill order it replays when the PTY's SSH host reconnects.
* Older hosts never send it, so a client promises no retry without it. */
pendingKillRecorded?: true
}
export type RuntimeTerminalWaitCondition = 'exit' | 'tui-idle'
// Why both spellings: the codex-* members were published by every host before the agent-neutral
// rename, so they are permanent — a client still has to read them off an older host. This build
// keeps a codex-* reason only where the matched wording is plausibly Codex's own; every matcher
// that inspects no agent publishes the agent-* spelling.
export type RuntimeTerminalWaitBlockedReason =
| 'codex-update-prompt'
| 'codex-trust-workspace'
| 'codex-cwd-prompt'
| 'codex-model-migration-prompt'
| 'codex-hooks-review-prompt'
| 'codex-interactive-prompt'
| 'agent-update-prompt'
| 'agent-trust-workspace'
| 'agent-cwd-prompt'
| 'agent-hooks-review-prompt'
| 'agent-interactive-prompt'
| 'agent-approval-prompt'
export type RuntimeTerminalWait = {
handle: string
condition: RuntimeTerminalWaitCondition
satisfied: boolean
status: RuntimeTerminalState
exitCode: number | null
exitCause?: TerminalExitCause
blockedReason?: RuntimeTerminalWaitBlockedReason
}