Merge remote-tracking branch 'origin/main' into pr20025

This commit is contained in:
Neil
2026-09-11 02:40:53 -07:00
210 changed files with 9967 additions and 3411 deletions
+4
View File
@@ -31,6 +31,10 @@
# the reviewable change, and pin LF because they are compared byte-for-byte.
# Not -diff: the shell diff is the review surface when a wrapper does change.
/src/main/__fixtures__/shell-wrapper-snapshots/*.txt linguist-generated=true text eol=lf
# Captured agent PTY transcripts. -text, not `text eol=lf` like the wrapper snapshots above:
# these carry real CR and CRLF bytes as the terminal emitted them, and line-ending
# normalisation on a Windows checkout would rewrite the evidence the fixture exists to be.
/src/main/runtime/__fixtures__/*.txt -text
# Generated runtime English subset: compared byte-for-byte by
# verify:localization-runtime-catalog, so a CRLF checkout would fail the gate.
/src/renderer/src/i18n/en-runtime-required.json linguist-generated=true text eol=lf
+29
View File
@@ -0,0 +1,29 @@
name: Pi owner runtime verification
on:
pull_request:
paths:
- 'src/main/pi/agent-status-handler-source.ts'
- 'tests/tools/pi-owner-runtime-smoke.mjs'
- '.github/workflows/pi-owner-runtime.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
runtime:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 20
env:
ORCA_BACKGROUND_LAUNCH: '1'
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
- name: Install pinned extension loader
run: npm install --prefix .cache/pi-owner --ignore-scripts --no-audit --no-fund @earendil-works/pi-coding-agent@0.83.0
- name: Verify real owner exit and hook delivery
run: node tests/tools/pi-owner-runtime-smoke.mjs .cache/pi-owner/node_modules/@earendil-works/pi-coding-agent
+28
View File
@@ -0,0 +1,28 @@
name: Pi extension provider verification
on:
pull_request:
paths:
- 'src/shared/commit-message-agent-specs-primary.ts'
- 'tests/tools/pi-provider-runtime-smoke.mjs'
- '.github/workflows/pi-provider-runtime.yml'
permissions:
contents: read
jobs:
runtime:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 20
env:
ORCA_BACKGROUND_LAUNCH: '1'
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
- name: Install pinned Pi runtime
run: npm install --prefix .cache/pi-provider --ignore-scripts --no-audit --no-fund @earendil-works/pi-coding-agent@0.84.2
- name: Verify extension model generation before and after
run: node tests/tools/pi-provider-runtime-smoke.mjs .cache/pi-provider/node_modules/@earendil-works/pi-coding-agent/dist/cli.js
+2
View File
@@ -103,7 +103,9 @@ docs/**
!docs/agent-skill-sharing-implementation-checklist.md
!docs/mobile-terminal-shortcut-bar.md
!docs/reference/
!docs/reference/agent-pty-transcript-capture.md
!docs/reference/agent-status-store.md
!docs/reference/antigravity-readiness-evidence.md
!docs/reference/git-compatibility.md
!docs/reference/headless-linux-server.md
!docs/reference/ime-regression-checklist.md
+4
View File
@@ -72,6 +72,10 @@ All changes must consider folder workspaces as well as git worktrees. Don't assu
The execution host owns agent status in one store, the hook server's, and every reader (sidebar, `worktree ps`, mobile, dashboard) subscribes to it. Before adding a producer, a cache, or a reader-side precedence rule, read [`docs/reference/agent-status-store.md`](./docs/reference/agent-status-store.md): new producers write into that store, and readers keep only presentation policy.
## Agent Terminal Screens
A rule that reads what an agent CLI paints on a terminal — readiness, blocked prompts, idle — must be written against a captured transcript, not a remembered screen. Record one with [`docs/reference/agent-pty-transcript-capture.md`](./docs/reference/agent-pty-transcript-capture.md), which keeps escapes and wrapping intact and scrubs account identifiers before they reach git. Antigravity readiness has no transcript yet and five failed attempts without one; before touching it, read [`docs/reference/antigravity-readiness-evidence.md`](./docs/reference/antigravity-readiness-evidence.md).
## Remote Wire Compatibility
Clients and remote Orca servers update independently, so mixed versions are the normal state. Before changing anything a paired client and host exchange — RPC params, stream frames, or the content either side publishes over them — follow [`docs/reference/remote-wire-compatibility.md`](./docs/reference/remote-wire-compatibility.md). A new optional field is safe; a new stream opcode must be capability-negotiated because decoders drop unknown opcodes silently; and changing what the host publishes reaches old clients even with no wire change.
+75
View File
@@ -10,6 +10,81 @@
}
},
"gates": [
{
"id": "runtime.connection-owned-host-status",
"title": "Host status recovers with its owning connection",
"maturity": "experimental",
"protection": "partial",
"owner": "runtime",
"layer": "service-integration-and-e2e",
"surfaces": [
"sidebar host status",
"desktop runtime connection",
"browser primary connection"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["remote-runtime"],
"coverageNotes": "Real authenticated sockets plus isolated desktop and headless hosts with desktop and browser viewers; deterministic lifecycle tests cover stale results and reader deadlines.",
"motivatingLinks": ["https://github.com/stablyai/orca/pull/19163"],
"invariant": "Failed bootstrap and authenticated reconnect converge without UI triggers; one connection owner publishes verified status, with no independent healthy status polling.",
"oracle": "Observe automatic recovery, retained runtime identity on failure, ordered publications, exact request counts, isolated viewer outages, and retirement on disconnect.",
"commands": [
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/runtime-host-status-owner.test.ts src/main/ipc/runtime-environment-status-recovery.test.ts src/main/ipc/runtime-environment-status-connection.test.ts src/renderer/src/store/slices/runtime-status-snapshot.test.ts src/renderer/src/web/web-runtime-status-owner.test.ts",
"ORCA_BACKGROUND_LAUNCH=1 ORCA_E2E_WEB_CLIENT=1 pnpm exec playwright test tests/e2e/runtime-host-status-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
],
"testFiles": [
"src/shared/runtime-host-status-owner.test.ts",
"src/main/ipc/runtime-environment-status-recovery.test.ts",
"src/main/ipc/runtime-environment-status-connection.test.ts",
"src/renderer/src/store/slices/runtime-status-snapshot.test.ts",
"src/renderer/src/web/web-runtime-status-owner.test.ts",
"tests/e2e/runtime-host-status-recovery.spec.ts"
],
"assertionRefs": [
{
"file": "src/main/ipc/runtime-environment-status-recovery.test.ts",
"assertions": [
"recovers a saved host after its first status check fails, without another UI request"
]
}
],
"evidenceRuns": [
{
"date": "2026-09-10",
"runner": "local",
"platform": "macos",
"command": "ORCA_BACKGROUND_LAUNCH=1 ORCA_E2E_WEB_CLIENT=1 pnpm exec playwright test tests/e2e/runtime-host-status-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"result": "passed",
"summary": "Desktop-host and headless-host journeys passed with desktop and browser viewers.",
"durationSeconds": 31.3
}
],
"runtimeBudget": {
"p95Seconds": 180,
"scope": "Target excluding builds; measured p95 not established."
},
"flakeHistory": {
"status": "soaking",
"evidence": "Local candidate runs passed; no sustained CI history yet."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "First-status-failure oracle failed on main 58ff95becb40 (one request instead of two) and passes on the candidate. E2E verifies candidate recovery, not a baseline comparison."
},
"performanceBudget": {
"required": false,
"evidence": "Deterministic tests assert one shared request and no healthy owner polling."
},
"promotionCriteria": ["Collect repeated CI runs without unexplained flakes."],
"knownGaps": [
"No live Linux, Windows, SSH, or mixed-version pair validation.",
"TCP interruption exercises reconnect, not a full real host process restart.",
"The outage begins on the first saved-host check, not by relaunching a preseeded desktop profile."
],
"demotionRule": "Keep experimental until repeated runs establish reliability; preserve request-count and lifecycle assertions."
},
{
"id": "mobile-push.headless-startup-and-policy",
"title": "Headless push lifecycle and mobile delivery policy",
@@ -0,0 +1,283 @@
/**
* Records a live agent CLI session through a real PTY into a test fixture, bytes intact.
*
* Why a PTY and not `agy | tee`: a pipe is not a terminal, so the CLI renders its
* non-interactive path — no alternate screen, no caret, no dialogs. The detector under
* test only ever sees the PTY shape, so that is the only shape worth capturing.
*
* Nothing here strips escapes, folds CRs, or rewraps lines: the transcript is written
* exactly as the terminal received it. See docs/reference/agent-pty-transcript-capture.md.
*/
import { createWriteStream, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import {
formatFindings,
redactTranscript,
scanTranscriptForSecrets
} from './pty-transcript-secret-scan.mjs'
const REPO_ROOT = resolve(import.meta.dirname, '..', '..')
const FIXTURE_DIR = join(REPO_ROOT, 'src', 'main', 'runtime', '__fixtures__')
const STOP_KEY = 0x1d // Ctrl-], consumed by the recorder and never forwarded to the agent.
const NAME_RE = /^[a-z0-9][a-z0-9-]*$/
const USAGE = `Capture a raw agent PTY transcript into src/main/runtime/__fixtures__/.
node config/scripts/capture-agent-pty-transcript.mjs --name <fixture-name> [options] -- <command> [args...]
node config/scripts/capture-agent-pty-transcript.mjs --scan <file...> [--redact]
Options
--name <fixture-name> Output fixture name, e.g. antigravity-ready-personal-non-gemini
--out <path> Write somewhere other than the fixture directory
--cols <n> --rows <n> Pin the PTY size (default: this terminal's size, else 120x40)
--duration <seconds> Stop unattended after N seconds
--send "<ms>:<text>" Type <text> into the PTY at <ms> (repeatable; \\r \\n \\t \\e escapes)
--note "<text>" Recorded in the <name>.meta.json sidecar
--scan <file...> Scan existing transcripts for identifiers/credentials and exit
--redact With --scan: rewrite each finding as a same-length placeholder
Press Ctrl-] to end a capture. That key is consumed here, so the agent keeps whatever
dialog it is showing — which is the only way to capture a dialog that owns the screen.`
function parseArgs(argv) {
const options = { cols: null, rows: null, duration: null, scan: [], sends: [], redact: false }
const command = []
let cursor = 0
let afterSeparator = false
while (cursor < argv.length) {
const arg = argv[cursor]
if (afterSeparator) {
command.push(arg)
cursor += 1
continue
}
if (arg === '--') {
afterSeparator = true
} else if (arg === '--redact') {
options.redact = true
} else if (arg === '--help' || arg === '-h') {
options.help = true
} else if (arg === '--scan') {
while (cursor + 1 < argv.length && !argv[cursor + 1].startsWith('--')) {
cursor += 1
options.scan.push(argv[cursor])
}
} else if (arg === '--send') {
cursor += 1
options.sends.push(parseSend(argv[cursor]))
} else if (arg.startsWith('--')) {
const key = arg.slice(2)
cursor += 1
options[key] = argv[cursor]
}
cursor += 1
}
for (const key of ['cols', 'rows', 'duration']) {
options[key] = options[key] == null ? null : Number(options[key])
}
return { options, command }
}
// String.fromCharCode, not a literal: the formatter rewrites an escape sequence into a raw
// control byte in source, which is unreadable and survives badly in diffs.
const ESC = String.fromCharCode(27)
const SEND_ESCAPES = { r: '\r', n: '\n', t: '\t', e: ESC, '\\': '\\' }
/** `"<ms>:<text>"` — a keystroke to deliver at a fixed offset, for an unattended dialog capture. */
function parseSend(value) {
const separator = String(value ?? '').indexOf(':')
if (separator === -1) {
throw new Error(`--send expects "<ms>:<text>", got ${String(value)}`)
}
const atMs = Number(value.slice(0, separator))
if (!Number.isFinite(atMs)) {
throw new Error(
`--send delay must be a number of milliseconds, got ${value.slice(0, separator)}`
)
}
const text = value
.slice(separator + 1)
.replace(/\\(.)/g, (whole, code) => SEND_ESCAPES[code] ?? whole)
return { atMs, text }
}
function runScan(files, redact) {
let failed = false
for (const file of files) {
const path = resolve(file)
const text = readFileSync(path, 'utf8')
if (redact) {
const { text: redacted, redacted: count } = redactTranscript(text)
writeFileSync(path, redacted)
console.log(`${file}: redacted ${count} span(s) in place, same length each.`)
continue
}
const findings = scanTranscriptForSecrets(text)
console.log(formatFindings(file, findings))
failed ||= findings.length > 0
}
return failed ? 1 : 0
}
function resolveSpawn(command) {
// node-pty cannot run a .cmd/.bat shim directly on Windows; those need cmd.exe.
if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(command[0])) {
return { file: 'cmd.exe', args: ['/c', `"${command[0]}"`, ...command.slice(1)] }
}
return { file: command[0], args: command.slice(1) }
}
async function runCapture(options, command) {
const name = options.name
if (typeof name === 'string' && !NAME_RE.test(name)) {
console.error(`--name must be lowercase kebab-case; got ${name}`)
return 2
}
const outPath = options.out ? resolve(options.out) : join(FIXTURE_DIR, `${name}.txt`)
mkdirSync(dirname(outPath), { recursive: true })
const pty = await import('node-pty').catch((error) => {
console.error(
`node-pty failed to load. Build it for plain node first:
node config/scripts/ensure-native-runtime.mjs --runtime=node
${String(error)}`
)
return null
})
if (pty === null) {
return 2
}
const cols = options.cols ?? process.stdout.columns ?? 120
const rows = options.rows ?? process.stdout.rows ?? 40
const { file, args } = resolveSpawn(command)
const term = pty.spawn(file, args, {
name: 'xterm-256color',
cols,
rows,
cwd: process.cwd(),
env: { ...process.env, TERM: 'xterm-256color' },
encoding: null
})
const sink = createWriteStream(outPath)
let recording = true
term.onData((chunk) => {
const bytes = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk
// Why recording stops before the kill: an agent repaints an idle frame on its way out, so
// a transcript that keeps writing through shutdown ends on that frame instead of on the
// state you stopped to capture. A mid-turn or dialog capture cannot survive that.
if (recording) {
sink.write(bytes)
}
process.stdout.write(bytes)
})
const wasRaw = process.stdin.isTTY === true && process.stdin.isRaw === true
if (process.stdin.isTTY) {
process.stdin.setRawMode(true)
}
process.stdin.resume()
let stopping = false
const stop = () => {
if (stopping) {
return
}
stopping = true
recording = false
try {
term.kill()
} catch {
// The agent may have exited on its own; the transcript is already on disk.
}
}
process.stdin.on('data', (chunk) => {
if (chunk.includes(STOP_KEY)) {
stop()
return
}
term.write(chunk.toString('binary'))
})
// Why scripted input: a dialog capture has to be driven, and CI (or an agent) has no TTY to
// type into. The keystrokes ride the same PTY a human's would, so the capture is unchanged.
const sendTimers = options.sends.map((send) => setTimeout(() => term.write(send.text), send.atMs))
const durationTimer = options.duration === null ? null : setTimeout(stop, options.duration * 1000)
const exitCode = await new Promise((resolveExit) => {
term.onExit(({ exitCode: code }) => resolveExit(code ?? 0))
})
for (const timer of sendTimers) {
clearTimeout(timer)
}
if (durationTimer !== null) {
clearTimeout(durationTimer)
}
if (process.stdin.isTTY) {
process.stdin.setRawMode(wasRaw)
}
process.stdin.pause()
await new Promise((done) => sink.end(done))
writeMeta(outPath, { command, cols, rows, note: options.note ?? null, exitCode })
const findings = scanTranscriptForSecrets(readFileSync(outPath, 'utf8'))
console.log(`\nTranscript: ${outPath}`)
console.log(formatFindings('scrub check', findings))
if (findings.length > 0) {
console.log(
`Scrub with:
node config/scripts/capture-agent-pty-transcript.mjs --scan ${outPath} --redact`
)
}
return 0
}
function writeMeta(outPath, details) {
const metaPath = outPath.replace(/\.txt$/, '.meta.json')
writeFileSync(
metaPath,
`${JSON.stringify(
{
capturedAt: new Date().toISOString(),
platform: process.platform,
command: details.command,
cols: details.cols,
rows: details.rows,
note: details.note,
exitCode: details.exitCode
},
null,
2
)}\n`
)
}
async function main() {
const { options, command } = parseArgs(process.argv.slice(2))
if (options.help === true) {
console.log(USAGE)
return 0
}
if (options.scan.length > 0) {
return runScan(options.scan, options.redact)
}
if (command.length === 0 || (options.name === undefined && options.out === undefined)) {
console.error(USAGE)
return 2
}
return runCapture(options, command)
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().then(
(code) => {
process.exitCode = code
},
(error) => {
console.error(error)
process.exitCode = 1
}
)
}
export { parseArgs, resolveSpawn }
@@ -0,0 +1,135 @@
// Finds account identifiers and credentials in a captured PTY transcript before it is committed.
import os from 'node:os'
// Why same-length replacements: a transcript's value is its exact wrapping and column
// alignment. Shortening a redacted span reflows the screen and destroys the evidence.
const EMAIL_DOMAIN = '@example.com'
const PLACEHOLDER_UUID = '00000000-0000-4000-8000-000000000000'
/** Ordered most-specific first; the first pattern to claim a span owns it. */
function buildPatterns() {
const username = os.userInfo().username
const hostname = os.hostname()
const patterns = [
{ kind: 'jwt', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}/g },
{ kind: 'google-api-key', re: /\bAIza[0-9A-Za-z_-]{20,}/g },
{ kind: 'google-refresh-token', re: /\b1\/\/[0-9A-Za-z_-]{20,}/g },
{ kind: 'vendor-key', re: /\b(?:sk-|ghp_|gho_|github_pat_|xoxb-|xoxp-)[A-Za-z0-9_-]{16,}/g },
{ kind: 'bearer-token', re: /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/gi },
{ kind: 'email', re: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g },
// Why a UUID counts: agy prints a resumable conversation id on exit, and installation and
// project ids look the same. They identify the operator's session, not just its shape.
{ kind: 'uuid', re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi },
{ kind: 'opaque-token', re: /\b[A-Za-z0-9_-]{40,}\b/g }
]
if (username.length >= 3) {
patterns.splice(5, 0, { kind: 'local-username', re: literalPattern(username) })
}
if (hostname.length >= 3) {
patterns.splice(5, 0, { kind: 'local-hostname', re: literalPattern(hostname) })
}
return patterns
}
function literalPattern(value) {
return new RegExp(value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')
}
/**
* @param {string} text raw transcript, escapes intact
* @returns {{kind: string, line: number, column: number, index: number, match: string}[]}
*/
export function scanTranscriptForSecrets(text) {
const claimed = []
const findings = []
for (const { kind, re } of buildPatterns()) {
re.lastIndex = 0
let match = re.exec(text)
while (match !== null) {
const start = match.index
const end = start + match[0].length
if (!claimed.some(([from, to]) => start < to && end > from)) {
claimed.push([start, end])
if (!isAlreadyScrubbed(kind, match[0])) {
findings.push({ kind, index: start, match: match[0], ...locate(text, start) })
}
}
match = re.exec(text)
}
}
return findings.sort((left, right) => left.index - right.index)
}
// Why: a scrubbed fixture must verify clean, so this scanner has to recognise its own
// placeholders — otherwise "prove it's gone" can never pass and the check gets ignored.
const PLACEHOLDER_DOMAIN_RE = /@(?:example\.(?:com|org|net)|localhost)$/i
function isAlreadyScrubbed(kind, match) {
if (kind === 'email') {
return PLACEHOLDER_DOMAIN_RE.test(match)
}
if (kind === 'uuid') {
return match.toLowerCase() === PLACEHOLDER_UUID
}
return /^(.)\1*$/.test(match)
}
function locate(text, index) {
let line = 1
let lineStart = 0
for (let cursor = 0; cursor < index; cursor += 1) {
if (text.charCodeAt(cursor) === 10) {
line += 1
lineStart = cursor + 1
}
}
return { line, column: index - lineStart + 1 }
}
/** Same-length stand-in so redaction cannot reflow the captured screen. */
export function placeholderFor(kind, length) {
if (kind === 'uuid' && length === PLACEHOLDER_UUID.length) {
return PLACEHOLDER_UUID
}
if (kind === 'email' && length > EMAIL_DOMAIN.length) {
return 'u'.repeat(length - EMAIL_DOMAIN.length) + EMAIL_DOMAIN
}
return kind === 'local-username' || kind === 'local-hostname'
? 'x'.repeat(length)
: 'X'.repeat(length)
}
/** @returns {{text: string, redacted: number}} */
export function redactTranscript(text) {
const findings = scanTranscriptForSecrets(text)
let out = ''
let cursor = 0
for (const finding of findings) {
out += text.slice(cursor, finding.index)
out += placeholderFor(finding.kind, finding.match.length)
cursor = finding.index + finding.match.length
}
return { text: out + text.slice(cursor), redacted: findings.length }
}
export function formatFindings(label, findings) {
if (findings.length === 0) {
return `${label}: clean — no account identifier or credential shapes found.`
}
const rows = findings.map(
(finding) => ` ${finding.line}:${finding.column} ${finding.kind} ${preview(finding.match)}`
)
return [`${label}: ${findings.length} finding(s) — scrub before committing.`, ...rows].join('\n')
}
// Why a codepoint test and not a character class: a control-byte range written as an escape is
// folded back into raw 0x00-0x1f bytes by the formatter, which makes this file binary to the VCS
// and leaves the one file gating real PTY data into history unreviewable in a diff.
function preview(value) {
const head = value.length <= 24 ? value : `${value.slice(0, 21)}...`
let printable = ''
for (const char of head) {
printable += (char.codePointAt(0) ?? 0) < 0x20 ? '?' : char
}
return printable
}
@@ -0,0 +1,133 @@
// The scrub gate is the only thing standing between a live agent transcript and a
// committed account identifier, so it is pinned on the shapes those transcripts carry.
import { readdirSync, readFileSync } from 'node:fs'
import os from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
formatFindings,
placeholderFor,
redactTranscript,
scanTranscriptForSecrets
} from './pty-transcript-secret-scan.mjs'
import { parseArgs, resolveSpawn } from './capture-agent-pty-transcript.mjs'
describe('pty transcript secret scan', () => {
it('finds the account row of a ready screen', () => {
const findings = scanTranscriptForSecrets('Antigravity CLI 1.1.17\njin.woo@acme.dev (Business)')
expect(findings).toHaveLength(1)
expect(findings[0]).toMatchObject({ kind: 'email', line: 2, column: 1 })
})
it('finds credentials an agent may echo while signing in', () => {
const kinds = scanTranscriptForSecrets(
[
'token: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dBjftJeZ4CVP',
'key: AIzaSyA1234567890abcdefghijklmnopqrstu',
'refresh: 1//0gLm34XyZabcdefghijklmnopqrstuvwx',
'Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345'
].join('\n')
).map((finding) => finding.kind)
expect(kinds).toEqual(['jwt', 'google-api-key', 'google-refresh-token', 'bearer-token'])
})
it('flags this machine’s own username, which a prompt line leaks', () => {
const username = os.userInfo().username
const findings = scanTranscriptForSecrets(`~/Users/${username}/orca/repo\n> `)
expect(findings.some((finding) => finding.kind === 'local-username')).toBe(true)
})
it('finds the resumable conversation id agy prints on exit', () => {
const findings = scanTranscriptForSecrets(
'Resume with -c (or command below):\nagy --conversation=26dc1986-9eec-456a-a534-d93e5c1076c2'
)
expect(findings).toHaveLength(1)
expect(findings[0].kind).toBe('uuid')
expect(placeholderFor('uuid', findings[0].match.length)).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/
)
})
it('reports a clean transcript as clean', () => {
const findings = scanTranscriptForSecrets('Antigravity CLI 1.1.17\nSonnet 4.6 (High)\n> ')
expect(findings).toEqual([])
expect(formatFindings('fixture', findings)).toContain('clean')
})
it('claims a span once, so a token inside an email is not double-reported', () => {
const findings = scanTranscriptForSecrets('longlivedaccountname@corp.internal')
expect(findings).toHaveLength(1)
})
it('passes a fixture that is already scrubbed, so "prove it is gone" can succeed', () => {
const scrubbed = `uuuu@example.com\n${'X'.repeat(44)}`
expect(scanTranscriptForSecrets(scrubbed)).toEqual([])
})
})
describe('redaction', () => {
it('replaces every finding with the same number of characters', () => {
// Why length matters: the fixture's value is its exact wrapping. A shorter
// replacement reflows the screen and invalidates the capture.
const text = 'Antigravity CLI 1.1.17\njin.woo@acme.dev (Antigravity Business)\n> '
const { text: redacted, redacted: count } = redactTranscript(text)
expect(count).toBe(1)
expect(redacted).toHaveLength(text.length)
expect(redacted).not.toContain('jin.woo@acme.dev')
expect(scanTranscriptForSecrets(redacted)).toEqual([])
expect(redactTranscript(redacted).redacted).toBe(0)
})
it('keeps a redacted email shaped like an email', () => {
expect(placeholderFor('email', 'a@b.example.com'.length)).toMatch(/^u+@example\.com$/)
})
it('leaves the rest of the screen byte-for-byte untouched', () => {
const text = 'line one\nuser@corp.io\nline three'
expect(redactTranscript(text).text.split('\n')[2]).toBe('line three')
})
})
describe('committed transcripts', () => {
// Why in CI and not just in the recorder: a transcript is committed once and read forever.
// The capture-time warning is skippable; this is not.
const fixtureDir = join(import.meta.dirname, '..', '..', 'src', 'main', 'runtime', '__fixtures__')
const transcripts = readdirSync(fixtureDir).filter((entry) => entry.endsWith('.txt'))
it.each(transcripts)('%s carries no account identifier or credential', (name) => {
const findings = scanTranscriptForSecrets(readFileSync(join(fixtureDir, name), 'utf8'))
expect(formatFindings(name, findings)).toContain('clean')
})
})
describe('capture argv', () => {
it('splits recorder options from the agent command', () => {
const { options, command } = parseArgs([
'--name',
'antigravity-ready-personal-non-gemini',
'--cols',
'120',
'--',
'agy',
'--model',
'sonnet'
])
expect(options.name).toBe('antigravity-ready-personal-non-gemini')
expect(options.cols).toBe(120)
expect(command).toEqual(['agy', '--model', 'sonnet'])
})
it('collects a multi-file scan list', () => {
const { options } = parseArgs(['--scan', 'a.txt', 'b.txt', '--redact'])
expect(options.scan).toEqual(['a.txt', 'b.txt'])
expect(options.redact).toBe(true)
})
it('routes a Windows shim through cmd.exe, which node-pty cannot spawn directly', () => {
expect(resolveSpawn(['agy.cmd', '--model', 'sonnet'])).toEqual(
process.platform === 'win32'
? { file: 'cmd.exe', args: ['/c', '"agy.cmd"', '--model', 'sonnet'] }
: { file: 'agy.cmd', args: ['--model', 'sonnet'] }
)
})
})
@@ -46,6 +46,7 @@ const WINDOWS_SHIM_SPAWN_ALLOWLIST = [
'config/scripts/electron-builder-config.test.mjs',
'config/scripts/ensure-native-runtime.test.mjs',
'config/scripts/live-remote-freeze-rpc.mjs',
'config/scripts/pty-transcript-secret-scan.test.mjs',
'config/scripts/remote-agent-session-authority-repro.mjs',
// Platform-local build paths; the win32 branch is dead code on both.
'config/scripts/build-mac-local.mjs',
@@ -0,0 +1,129 @@
# Capturing an agent PTY transcript
Orca's readiness and blocked-prompt rules are text rules over what an agent CLI paints on a
terminal. They are only as good as the screens they were written against. This is how to record
one, byte for byte, so a rule can be pinned to evidence instead of to a remembered screen.
Related: [`antigravity-readiness-evidence.md`](./antigravity-readiness-evidence.md) names the
specific Antigravity transcripts that are still missing and what each one decides.
## The recorder
```
node config/scripts/capture-agent-pty-transcript.mjs --name <fixture-name> [options] -- <command> [args...]
```
It allocates a real PTY, spawns the agent inside it, mirrors the session to your terminal so you
can drive it by hand, and appends every byte it receives to
`src/main/runtime/__fixtures__/<fixture-name>.txt`. It does not strip escapes, fold `\r`, rewrap
lines, or normalise anything — the file is what the terminal received.
- **Ending a capture:** press <kbd>Ctrl</kbd>+<kbd>]</kbd>. The recorder consumes that key and
never forwards it, which is the only way to end a capture _while a dialog still owns the
screen_. Quitting the agent instead would first dismiss the dialog you came to record.
- `--cols N --rows M` pin the PTY size (default: your terminal's). Wrapping is part of the
evidence, so record the size — the sidecar does it for you.
- `--duration S` stops unattended after S seconds, for a screen that needs no interaction.
- `--send "<ms>:<text>"` types into the PTY at a fixed offset, repeatable, with `\r` `\n` `\t` `\e`
escapes. A dialog capture has to be driven, and an unattended run (CI, or an agent) has no TTY to
type into; the keystrokes ride the same PTY a human's would. For example, the committed
`antigravity-dialog-model-picker.txt` was recorded with
`--duration 24 --send "14000:/model" --send "16000:\r"`, which leaves the picker owning the
screen when the capture stops.
- `--note "<text>"` records the account type, plan, model and CLI version in the sidecar.
- `--out <path>` writes outside the fixture directory (use it for a first dry run).
Each capture also writes `<fixture-name>.meta.json` with the timestamp, platform, command,
PTY size, note and exit code. Commit it with the transcript; the version and account type behind
a screen are not recoverable from the bytes.
**Prerequisite:** `node-pty` must be built for plain Node:
```
node config/scripts/ensure-native-runtime.mjs --runtime=node
```
Orca itself does not need to be running, and the recorder never touches Orca state.
### Platform notes
- **macOS / Linux:** nothing special. `TERM=xterm-256color` is set for the child.
- **Windows:** run it from Windows Terminal / PowerShell, not a Git Bash (MSYS) pane — MSYS
rewrites arguments that start with `/`, which mangles the `cmd.exe /c` hand-off. A `.cmd` or
`.bat` agent shim cannot be spawned by node-pty directly, so the recorder routes those through
`cmd.exe` for you.
- **WSL:** capture _inside_ the distro (run the recorder from the distro's checkout). Recording
`wsl.exe` from the Windows side adds the login-shell banner to the transcript.
- **SSH:** record on the execution host. A transcript recorded locally is not evidence about what
a remote agent prints.
## Privacy: scrub before committing
A live agent screen routinely contains things that must not enter git history:
| Scrub | Why |
| ---------------------------------------------------------------------- | ---------------------------------------------------- |
| Account email / sign-in identifier | The account row on a ready screen prints it verbatim |
| Org, tenant or team name | Identifies a customer |
| Machine hostname and OS username | Appear in prompts, paths and the OSC title |
| Absolute home paths (`/Users/<you>`, `C:\Users\<you>`) | Contain the username |
| JWTs, `AIza…` keys, `1//…` refresh tokens, `Bearer …`, `sk-…`, `ghp_…` | Live credentials; a sign-in screen can echo one |
| Private repo, branch and ticket names | Leak roadmap detail |
| Anything you pasted into the agent during the capture | You typed it; it is in the transcript |
The recorder scans the file as soon as the capture ends and prints every hit with a line and
column. To scrub:
```
node config/scripts/capture-agent-pty-transcript.mjs --scan src/main/runtime/__fixtures__/<name>.txt --redact
```
Redaction replaces each finding with a **same-length** placeholder (`u…u@example.com`, `XXXX…`).
Length matters: a transcript's value is its exact wrapping and column alignment, and a shorter
replacement reflows the screen and destroys the evidence.
### Verify it is gone
1. `node config/scripts/capture-agent-pty-transcript.mjs --scan src/main/runtime/__fixtures__/<name>.txt`
must print `clean` and exit `0`. It recognises its own placeholders, so a scrubbed file passes.
2. Grep for the specifics the scanner cannot know:
`rg -n -i -- "$(whoami)|<your-email>|<your-org>|<your-hostname>" src/main/runtime/__fixtures__/<name>.txt`
3. Read it once with escapes visible: `LC_ALL=C cat -v src/main/runtime/__fixtures__/<name>.txt`.
The scanner matches shapes; only a human catches a project name.
4. Check the sidecar too — `--note` text is free-form and is committed.
`config/scripts/pty-transcript-secret-scan.test.mjs` re-scans every committed
`__fixtures__/*.txt`, so a transcript that skips step 1 fails the suite.
## Consuming a transcript in a test
Feed the raw bytes through the runtime rather than into a matcher directly: escape handling,
tail retention and title tracking all live in `onPtyData`, and a rule tested on pre-normalised
text is tested on something no pane ever sees.
`src/main/runtime/agent-transcript-pane-test-harness.ts` builds the pane;
`src/main/runtime/terminal-interactive-wait-visibility.test.ts` (cursor-agent) and
`src/main/runtime/antigravity-readiness-transcripts.test.ts` (Antigravity) are the two consumers.
## Worked example: the Antigravity captures
The six committed `antigravity-*.txt` fixtures were recorded this way on macOS against
`agy` 1.1.25. Two points generalise:
- **Reach a state without mutating the operator's config.** The ready-screen captures ran in a
directory the CLI already trusted, so no trust answer was written. Where a dialog could only be
reached by signing the operator out or deleting their settings, it was left uncaptured and
recorded as such rather than forced.
- **An environment variable is a legitimate capture knob** where a setting is not.
`AGY_CLI_HIDE_ACCOUNT_INFO=1` produced a second ready screen with no account row, which is
evidence no amount of reasoning about the first screen could have supplied. It changes nothing
on disk.
## Known gap in the existing captures
The three `cursor-agent-*.txt` fixtures contain **no escape bytes and no carriage returns**.
Whatever produced them went through a renderer and a clipboard, so they preserve wording and
box-drawing glyphs but not the caret, the cursor moves, the repaints, or whether the CLI uses the
alternate screen buffer. They are good enough for the wording-based rules built on them and are
not evidence for anything else. New captures made with this recorder keep those bytes; the
Antigravity scaffold asserts their presence so a pasted screen cannot pass as a capture.
@@ -0,0 +1,263 @@
# Antigravity readiness: what the transcripts show
`findAntigravityReadyPromptIndex` in `src/main/runtime/terminal-wait-detection.ts` decides whether
an Antigravity pane is ready for a prompt. It has been written five times, each version tuned
against a five-line screen typed from memory into a `.spec.ts` fixture. Three of the first four
were found worse than the bug they replaced, and the fifth was reverted.
Real transcripts now exist. They were recorded from a live `agy` on macOS with
[`agent-pty-transcript-capture.md`](./agent-pty-transcript-capture.md) and are committed under
`src/main/runtime/__fixtures__/`. `src/main/runtime/antigravity-readiness-transcripts.test.ts`
replays them through the runtime.
**Headline: on real output the current detector is inverted.** It refuses a genuinely ready screen
and accepts a live model picker. The five attempts argued about which extra condition to add; none
of them had noticed that the condition they all shared — a line beginning with the model name —
never matches a real Antigravity ready screen at all.
## Versions
| Thing | Value |
| ------------------------- | ----------------------------- |
| `agy --version` | `1.1.25` |
| Banner printed by the TUI | `Antigravity CLI 1.2.0` |
| Captured | 2026-09-10, macOS, 120x40 PTY |
The binary and its own banner disagree. Any rule keyed to a version string must read the banner,
not `--version`, and must tolerate the two disagreeing.
## What the captures are
| Fixture | What it is |
| -------------------------------------------- | --------------------------------------------------------- |
| `antigravity-ready-api-key-gemini-model.txt` | Ready screen, API-key identity, Gemini 3.7 Flash (Low) |
| `antigravity-ready-account-info-hidden.txt` | The same ready screen with `AGY_CLI_HIDE_ACCOUNT_INFO=1` |
| `antigravity-dialog-trust-workspace.txt` | Workspace trust dialog, live and unanswered |
| `antigravity-dialog-model-picker.txt` | `/model` picker, live and unanswered |
| `antigravity-dialog-command-palette.txt` | Slash-command palette, live and unanswered |
| `antigravity-dialog-dismissed.txt` | `/model` picker dismissed with esc, then settled |
| `antigravity-busy-mid-turn.txt` | A real turn, recording stopped while the spinner was live |
| `antigravity-busy-turn-ended.txt` | The same turn after it ended and the composer returned |
## What could not be captured, and why
Nothing below was faked. Each is a case the recorder could not reach without changing the
operator's account state or configuration, which is out of bounds.
| Missing | Why |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `antigravity-ready-business-non-gemini.txt` | This machine has no OAuth session — the CLI prints _"You are currently not signed in"_ and authenticates from `GEMINI_API_KEY`. Reaching a Business ready screen means signing someone in. |
| A non-Gemini model on any ready screen | `agy models` offers 11 models, all Gemini, and `settings.json` pins `modelProvider: gemini`. A non-Gemini row is not reachable from this account. |
| `antigravity-dialog-sign-in.txt` | Unsetting `GEMINI_API_KEY` does not reach the sign-in dialog; the CLI refuses to start because `modelProvider` is pinned. Reaching it means editing the operator's `settings.json`. |
| `antigravity-dialog-theme-picker.txt` | There is no `/theme` command in 1.2.0 (`Unknown command: /theme`). The picker appears only in first-run onboarding, which means deleting the operator's config. |
| `antigravity-dialog-privacy-notice.txt` | First-run onboarding, as above. |
| `antigravity-dialog-update-banner.txt` | Cannot be forced; no update was pending during the session. |
Each remains as a named, skipping case in the suite so it is visible rather than forgotten.
## What the transcripts show
### 1. The ready screen's model row is not at the start of a line
The ready screen prints a block-glyph logo down the left, and the identity, model and path rows are
painted **on the same physical lines as the logo**. What Orca derives is:
```
▀▀▀▀▀▀ Gemini API key
▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low)
▄▀▀ ▀▀▄ ~
```
The detector requires `normalized.startsWith('gemini', trimmedStart)` on a trimmed line. The
trimmed line starts with `▀`. It never matches. Measured three ways on the real screen:
| Input | `isKnownReadyPromptPreview` |
| ------------------------------------------------------ | --------------------------- |
| Real ready screen | `false` |
| The same screen with the logo glyphs stripped | `true` |
| Real ready screen followed by the live `/model` picker | `true` |
So the logo — decoration, and suppressible with `AGY_CLI_HIDE_LOGO` — is what decides readiness
today, and the live dialog is what supplies the model line the ready screen could not.
### 2. The dialog is what satisfies the model rule
`/model` prints its options one per line:
```
Gemini 3.8 Flash
> Gemini 3.7 Flash (current)
Gemini 3.1 Pro
```
Those lines _do_ begin with `Gemini`, and a bare `>` composer line sits earlier in the same tail
from before the picker opened. Both halves of the rule are satisfied **while a dialog owns the
screen**, and the pane reads ready. This is the false-ready hazard the last three attempts were
each trying to close, reproduced from a real capture.
### 3. `>` is the dialog selection marker, not only the composer caret
Every dialog uses `>` to mark the highlighted row: `> Yes, I trust this folder`,
`> Gemini 3.7 Flash (current)`, `> /add-dir`. The idle composer is a line whose whole trimmed
content is `>`. That distinction is the only thing separating them, which means the relaxation
proposed in PRs #15840 and #15852 — accept any line _beginning_ with `>` — would make the trust
dialog and the model picker read as ready. On 1.2.0 the idle composer is a bare `>`; those PRs'
1.1.17 mode-banner claim could not be reproduced here and may be mode-specific.
### 4. There is no email account row, and the row can be switched off entirely
For an API-key user the identity row reads literally `Gemini API key`. There is no `@`, no
domain, nothing an account-row rule can key on. Separately, `AGY_CLI_HIDE_ACCOUNT_INFO=1` — a
supported environment variable in the binary — removes the row from a fully ready screen, which
`antigravity-ready-account-info-hidden.txt` captures.
### 5. Dialogs are drawn two different ways, and the banner is never reprinted
The trust dialog and the sign-in splash take the **alternate screen** (`ESC[?1049h` … `ESC[?1049l`).
The model picker and command palette are drawn **in place on the main screen** with erase-to-EOL.
After dismissal the CLI prints `⎿ Exited /model command` and redraws the composer — it does **not**
reprint the banner. The header stays where it was at startup.
### 6. Rows are positioned with cursor addressing, not newlines
The status row is written with absolute and relative moves (`ESC[13;99H`, `ESC[83X ESC[83C`), so
`? for shortcuts` and `Gemini 3.7 Flash · low` end up on one derived line. Any rule that assumes
one screen row equals one `\n`-delimited line is reading a different document than the user sees.
## 8. Busy frames park the caret exactly like idle frames — the spinner is what differs
The frame that ends a turn-in-progress and the frame that ends an idle screen park the cursor with
the **same bytes**. Only the hint row differs, and the park erases it:
```
idle: ? for shortcuts ESC[83X ESC[83C Gemini 3.7 Flash · low CR ESC[2A ESC[2C ESC[?25h
busy: esc to cancel ESC[85X ESC[85C Gemini 3.7 Flash · low CR ESC[2A ESC[2C ESC[?25h
```
So a rule that keys on "the caret is the last thing in the tail" cannot tell busy from idle **on the
frame alone**. What saves it is what comes next. Each spinner tick is its own repaint with its own
park, two rows higher than the frame's:
```
ESC[?25l CR ESC[2A ⣯ Generating ESC[11D ESC[?25h
ESC[?25l CR ESC[2A ⣟ Generating. ESC[12D ESC[?25h
```
That second `CR ESC[2A` splices the composer row away, so the retained tail during a live turn ends
on the spinner row, not on the caret. Measured on `antigravity-busy-mid-turn.txt`:
| Capture | last retained line | bare `>` line present |
| -------------------------------------------- | ------------------ | --------------------- |
| `antigravity-ready-api-key-gemini-model.txt` | `>` | **yes** |
| `antigravity-busy-mid-turn.txt` | `⣟ Generating...` | **no** |
**Consequence for a caret-based rule:** it already answers "not ready" for a real mid-turn capture,
because there is no bare caret in the tail to match. A constructed input that keeps the park bytes
and only edits the status text is not faithful to a live turn — a live turn has a spinner row
repainting _below_ the composer.
**The residual window, and the clause it implies.** Between a frame park and the next spinner tick
the tail does end on the bare caret and is indistinguishable from idle. The gap is one tick
interval. Any readiness path gated on sustained quiescence is safe, because ticks keep arriving and
the pane is never quiet; a path that only inspects retained text is not. For those paths the
evidence supports one clause, and only one:
> **A braille glyph (U+2800–U+28FF) on the last visible line of the retained tail means working.**
That predicate already exists in this file for cursor-agent (`CURSOR_BUSY_SPINNER_RE`) and should be
reused rather than reinvented. It must be scoped to the **last visible line**, not the whole tail:
a first-run transcript prints `⠾ Signing in...` during startup, which would otherwise pin a ready
screen as busy forever.
Nothing else in the capture distinguishes the two states. The hint row (`esc to cancel` versus
`? for shortcuts`) is erased by the park in both cases, the park offsets are identical, and
`ESC[?25l`/`ESC[?25h` fencing appears around every repaint, idle or busy.
## Confirmed / refuted, by attempt
Evidence column names the fixture; all quoted text is from the committed transcripts.
### Attempt 1 — the rule at HEAD
| # | Claim | Verdict | Evidence |
| ---- | -------------------------------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1.1 | A ready screen prints the banner `Antigravity CLI` | **Confirmed** | `Antigravity CLI 1.2.0` in both ready fixtures |
| 1.1b | …and its last occurrence in the tail is the live one | **Refuted** | The trust dialog's own body says _"Antigravity CLI requires permission to read, edit, and execute files here"_, so `lastIndexOf` lands inside the dialog |
| 1.2 | The model row begins with the vendor word `Gemini` | **Refuted** | `▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low)` — the logo precedes it; never at line start |
| 1.3 | The caret line's whole trimmed content is `>` | **Confirmed** on 1.2.0 idle | bare `>` in both ready fixtures |
| 1.3b | …and only the composer prints `>` | **Refuted** | `> Yes, I trust this folder`, `> Gemini 3.7 Flash (current)`, `> /add-dir` |
| 1.4 | A ready screen prints the workspace path on its own line | **Refuted** | the path shares its line with logo glyphs (`▄▀▀ ▀▀▄ ~`) |
### Attempt 2 (loop 1) — blacklist the model line
| # | Claim | Verdict | Evidence |
| --- | ------------------------------------------ | ----------- | ---------------------------------------------------------------------------------------------------------------- |
| 2.1 | Dialog model-row wording is enumerable | **Refuted** | the palette lists 50+ commands with free-form descriptions; the picker prints whatever models the account offers |
| 2.2 | A dialog never reproduces a real model row | **Refuted** | the `/model` picker prints four real model rows, one per line, at line start |
### Attempt 3 (loop 2) — structural ordering on `headerIndex`
| # | Claim | Verdict | Evidence |
| --- | -------------------------------------------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------- |
| 3.1 | A live dialog is printed below the ready chrome | **Confirmed** for in-place dialogs | picker and palette append below the composer |
| 3.2 | The banner is reprinted when a dialog is dismissed | **Refuted** | `antigravity-dialog-dismissed.txt` shows `⎿ Exited /model command` and a redrawn composer, no banner |
| 3.3 | Antigravity does not use the alternate screen | **Refuted** | `ESC[?1049h` opens the trust dialog and the sign-in splash |
| 3.4 | No full repaint per keystroke | **Partly refuted** | typing `/mod` repaints the palette region on each keystroke with `ESC[K` |
Because of 3.2, `headerIndex` cannot be the anchor: it never advances. Ordering can only be
expressed against the model/caret positions, which is what 1.2 and 1.3b just invalidated.
### Attempt 4 (loop 3) — require a positive account row
| # | Claim | Verdict | Evidence |
| --- | ---------------------------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| 4.1 | Every ready screen prints an account row | **Refuted, twice** | API-key identity prints `Gemini API key` (no `@`); `AGY_CLI_HIDE_ACCOUNT_INFO=1` removes the row entirely |
| 4.2 | A startup dialog never contains an `@`-and-`.` token | **Not reachable here** | none of the captured dialogs contains one, but the palette shows free-form skill descriptions, which are user-authored text |
| 4.3 | The account row is distinguishable from prose | **Refuted** | the row is not a distinct line; it shares one with the logo |
### Attempt 5 (PR #19749, reverted) — ordering + account row
| # | Claim | Verdict | Evidence |
| --- | -------------------------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 5.1 | Ordering plus an account row separates ready from dialog | **Refuted** | the account row is optional (4.1) and the ordering anchor never moves (3.2) |
| 5.2 | Executing both builds was sufficient verification | **Refuted** | the executed input was the hand-written fixture, so the check reproduced the fixture's assumptions. The real screen disagrees with that fixture on the model row, the path row and the account row |
| 5.3 | The wedge is a model-name problem | **Refuted** | it is a line-start problem. Even `Gemini 3.7 Flash (Low)` — a Gemini model — fails, because a logo glyph precedes it |
### Cross-cutting
| # | Question | Answer |
| --- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| X1 | Does `agy` set an OSC title distinguishing busy from idle? | **No.** Not one OSC title sequence appears in any capture. Title-based readiness is unavailable for this agent |
| X2 | Does it repaint with bare `\r`? | **Yes**, constantly, plus `ESC[K` and absolute cursor moves |
| X3 | Does the caret survive in the tail? | **Yes** — a bare `>` line is present in every ready capture |
| X4 | Banner-to-caret distance | ~8 derived lines on a 120x40 PTY; the banner falls outside the 6-line preview window, so only the full retained tail can see it |
| X5 | Pane title on the trust screen versus ready | Identical: none |
## Can attempt six be written?
Yes — but not as a variation on any of the five. Every one of them refined a predicate over
`\n`-delimited lines, and that is the layer where the evidence says the information is not.
What the captures support:
- **The one stable, dialog-free ready marker is a line whose entire trimmed content is `>`.** It is
present in every ready capture and absent from every dialog capture, because a dialog's `>` always
carries its selected row's label. This is a much narrower rule than any attempt used, and it is
the only one that survived contact with the transcripts.
- **Drop the model-row requirement.** It matches dialogs and not ready screens. Keeping it inverted
the detector.
- **Do not require an account row.** It is optional by environment variable and carries no email for
API-key users.
- **Do not anchor on `headerIndex`.** The banner is printed once and never reprinted.
- **The blocked-signal path already works** for the trust dialog: `antigravity-dialog-trust-workspace.txt`
is correctly refused today, by wording, not by structure.
What is still unknown and should be captured before shipping: the sign-in, theme, privacy and
update dialogs, and any ready screen where the composer is not idle (accept-edits and plan mode,
which PRs #15840 and #15852 describe from a screenshot). A bare-`>` rule is only as good as the
claim that those modes still end on a bare `>`; that claim is untested.
The honest summary is that this is a screen-shaped problem being solved with line-shaped tools. A
rule over the derived tail can be made much better than what ships today, but the durable fix is to
ask the terminal emulator what the bottom row of the screen actually is, rather than inferring it
from a byte stream that was written with cursor addressing.
@@ -9,6 +9,7 @@ vi.mock('react-native', async () => {
const Text = ({ children, ...props }: { children?: unknown }): unknown =>
React.createElement('Text', props, children)
return {
ActivityIndicator: 'ActivityIndicator',
Animated: {
Text,
Value: class {
@@ -268,12 +269,12 @@ describe('MobileNativeChatMessage', () => {
expect(tree.root.findAllByType('Wrench' as never)).toHaveLength(0)
})
it('renders the turn status row under a user message', () => {
it('renders the settled turn status row under a user message', () => {
const tree = render(userMessage([{ type: 'text', text: 'go' }]), {
structuredActivityUi: true,
turnStatus: { startedAt: Date.now(), thinking: true, workedSeconds: null }
turnStatus: { startedAt: Date.now() - 3_000, thinking: false, workedSeconds: 3 }
})
expect(textIn(tree.root)).toContain('Thinking')
expect(textIn(tree.root)).toContain('Worked for 3s')
})
it('does not render a turn status row without one', () => {
@@ -82,7 +82,7 @@ function MobileNativeChatMessageImpl({
/** Multiplies all chat text sizes for pinch-to-zoom (1 = no change). */
fontScale?: number
onOpenFile?: (relativePath: string) => void
/** This turn's status row, rendered under a user message (desktop parity). */
/** This settled turn's status row, rendered under its user message. */
turnStatus?: NativeChatTurnStatus | null
/** Whether the turn caret has disclosed this turn's activity. */
turnExpanded?: boolean
@@ -73,6 +73,7 @@ export function MobileNativeChatOverlay({
agentWorking={controller.nativeChatAgentWorking}
canStop={controller.nativeChatCanStop}
structuredActivityUi={controller.nativeChatStructured}
turnIndicator={controller.nativeChatTurnIndicator}
workingStartedAt={controller.nativeChatWorkingStartedAt}
settledTurns={controller.nativeChatSettledTurns}
streaming={streaming}
@@ -7,18 +7,8 @@ vi.mock('react-native', async () => {
const Text = ({ children, ...props }: { children?: unknown }): unknown =>
React.createElement('Text', props, children)
return {
Animated: {
Text,
Value: class {
constructor(private value: number) {}
setValue(next: number): void {
this.value = next
}
},
loop: (animation: unknown) => animation,
sequence: () => ({ start: vi.fn(), stop: vi.fn() }),
timing: () => ({ start: vi.fn(), stop: vi.fn() })
},
ActivityIndicator: (props: Record<string, unknown>) =>
React.createElement('ActivityIndicator', props),
Pressable: ({ children, ...props }: { children?: unknown }) =>
React.createElement('Pressable', props, children),
Text,
@@ -49,6 +39,7 @@ describe('MobileNativeChatTurnStatus', () => {
startedAt: number | null
thinking: boolean
workedSeconds?: number | null
activityText?: string | null
expanded?: boolean
onToggleExpanded?: () => void
}): ReactTestRenderer {
@@ -61,12 +52,16 @@ describe('MobileNativeChatTurnStatus', () => {
const labels = (node: ReactTestInstance): string[] =>
node.findAllByType('Text' as never).map((text) => String(text.children.join('')))
it('reads "Thinking" before the turn produces output', () => {
const spinners = (node: ReactTestInstance): ReactTestInstance[] =>
node.findAllByType('ActivityIndicator' as never)
it('reads "Thinking" beside one spinner while the turn reasons', () => {
const tree = render({ startedAt: Date.now(), thinking: true })
expect(labels(tree.root)).toEqual(['Thinking'])
expect(spinners(tree.root)).toHaveLength(1)
})
it('counts up once the turn is producing output', () => {
it('counts up on that same single row when the turn is not reasoning', () => {
const startedAt = Date.now()
const tree = render({ startedAt, thinking: false })
expect(labels(tree.root)).toEqual(['Working for 0s'])
@@ -74,6 +69,19 @@ describe('MobileNativeChatTurnStatus', () => {
vi.advanceTimersByTime(12_000)
})
expect(labels(tree.root)).toEqual(['Working for 12s'])
expect(spinners(tree.root)).toHaveLength(1)
})
it('lets provider activity text beat both fallbacks and hold the clock', () => {
const tree = render({
startedAt: Date.now(),
thinking: true,
activityText: 'Running pnpm test'
})
expect(labels(tree.root)).toEqual(['Running pnpm test'])
expect(spinners(tree.root)).toHaveLength(1)
// No label consumes the duration, so nothing schedules a tick for it.
expect(vi.getTimerCount()).toBe(0)
})
it('settles to a tappable "Worked for" row that toggles the turn', () => {
@@ -98,9 +106,10 @@ describe('MobileNativeChatTurnStatus', () => {
expect(labels(tree.root)).toEqual(['Worked for 5s'])
})
it('holds no interval once the turn has settled', () => {
render({ startedAt: Date.now(), thinking: false, workedSeconds: 5 })
it('holds no interval, and no spinner, once the turn has settled', () => {
const tree = render({ startedAt: Date.now(), thinking: false, workedSeconds: 5 })
expect(vi.getTimerCount()).toBe(0)
expect(spinners(tree.root)).toHaveLength(0)
})
it('announces the live row to assistive tech', () => {
@@ -1,7 +1,8 @@
import { useEffect, useRef, useState } from 'react'
import { Animated, Pressable, StyleSheet, Text, View } from 'react-native'
import { useEffect, useState } from 'react'
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'
import { ChevronRight } from 'lucide-react-native'
import {
formatNativeChatActiveTurnLabel,
formatNativeChatTurnStatusLabel,
NATIVE_CHAT_TURN_STATUS_COPY,
nativeChatElapsedSeconds
@@ -25,48 +26,38 @@ function useElapsedSeconds(startedAt: number | null, counting: boolean): number
return counting ? nativeChatElapsedSeconds(startedAt, mountedAt, now) : 0
}
/** The per-turn status row — "Thinking", then "Working for 12s" while the turn
* runs, settling to a tappable "Worked for 3m 4s" that discloses the turn's
* tool activity. Desktop parity: `NativeChatWorkingStatus`. */
/** The per-turn status row. While the turn runs it is the one live indicator — a
* spinner beside what the provider says it is doing, else "Thinking", else
* "Working for 12s". It settles to a tappable "Worked for 3m 4s" that discloses
* the turn's tool activity. Desktop parity: `NativeChatTurnActivityLine` for the
* live row, `NativeChatWorkingStatus` for the settled one. */
export function MobileNativeChatTurnStatus({
startedAt,
thinking,
workedSeconds,
activityText,
expanded = false,
onToggleExpanded
}: {
startedAt: number | null
thinking: boolean
workedSeconds?: number | null
/** Provider activity copy for a live turn; outranks the other two labels. */
activityText?: string | null
expanded?: boolean
onToggleExpanded?: () => void
}): React.JSX.Element {
const counting = !thinking && workedSeconds == null
const settled = workedSeconds != null
const counting = !settled && !thinking && !activityText?.trim()
const elapsedSeconds = useElapsedSeconds(startedAt, counting)
const label = formatNativeChatTurnStatusLabel({ thinking, workedSeconds, elapsedSeconds })
const label = settled
? formatNativeChatTurnStatusLabel({ thinking, workedSeconds, elapsedSeconds })
: formatNativeChatActiveTurnLabel({ activityText, thinking, elapsedSeconds })
const pulse = useRef(new Animated.Value(1)).current
useEffect(() => {
if (!thinking) {
pulse.setValue(1)
return
}
const animation = Animated.loop(
Animated.sequence([
Animated.timing(pulse, { toValue: 0.45, duration: 700, useNativeDriver: true }),
Animated.timing(pulse, { toValue: 1, duration: 700, useNativeDriver: true })
])
)
animation.start()
return () => animation.stop()
}, [pulse, thinking])
const rowStyle = [styles.row, thinking ? null : styles.rowSettled]
if (workedSeconds != null && onToggleExpanded) {
if (settled && onToggleExpanded) {
return (
<Pressable
style={({ pressed }) => [...rowStyle, pressed && styles.pressed]}
style={({ pressed }) => [styles.row, styles.rowSettled, pressed && styles.pressed]}
onPress={onToggleExpanded}
hitSlop={6}
accessibilityRole="button"
@@ -83,11 +74,14 @@ export function MobileNativeChatTurnStatus({
return (
<View
style={rowStyle}
style={[styles.row, settled ? styles.rowSettled : null]}
accessibilityLiveRegion="polite"
accessibilityLabel={NATIVE_CHAT_TURN_STATUS_COPY.responding}
>
<Animated.Text style={[styles.label, thinking && { opacity: pulse }]}>{label}</Animated.Text>
{settled ? null : <ActivityIndicator size="small" color={colors.textMuted} />}
<Text style={styles.label} numberOfLines={1}>
{label}
</Text>
</View>
)
}
@@ -109,7 +103,8 @@ const styles = StyleSheet.create({
},
label: {
color: colors.textMuted,
fontSize: typography.bodySize
fontSize: typography.bodySize,
flexShrink: 1
},
caretOpen: {
transform: [{ rotate: '90deg' }]
@@ -73,6 +73,7 @@ type Overrides = {
onSend?: (text: string) => Promise<boolean>
pending?: Parameters<typeof MobileNativeChatView>[0]['pending']
structuredActivityUi?: boolean
turnIndicator?: Parameters<typeof MobileNativeChatView>[0]['turnIndicator']
agentWorking?: boolean
canStop?: boolean
sendSurfaceId?: string
@@ -273,20 +274,74 @@ describe('MobileNativeChatView', () => {
return (renderedRow(id) as { props: Record<string, unknown> }).props
}
function footerProps(): Record<string, unknown> | null {
const list = renderer!.root.find((node) => node.type === 'FlatList')
const footer = list.props.ListFooterComponent as
| { props: Record<string, unknown> }
| null
| undefined
return footer?.props ?? null
}
function workingIndicators(): ReactTestInstance[] {
return renderer!.root.findAll((node) => node.type === 'WorkingIndicator')
}
it('gives the live user turn a status row and drops the three-dot indicator', async () => {
const folded = [userTurn('u1', 'go')]
it('puts the live status at the turn tail and drops the three-dot indicator', async () => {
const folded = [userTurn('u1', 'go'), assistantTurn('a1', 'still working')]
await render({ messages: folded, folded, structuredActivityUi: true, agentWorking: true })
const props = rowProps('u1')
expect(props.structuredActivityUi).toBe(true)
expect(props.turnStatus).toMatchObject({ thinking: true, workedSeconds: null })
expect(props.turnStatus).toBeNull()
// Nothing reports reasoning, so the one live footer counts instead of guessing.
expect(footerProps()).toMatchObject({ thinking: false, workedSeconds: null })
expect(listIds().at(-1)).toBe('a1')
expect(props.activeTurnIsWorking).toBe(true)
expect(workingIndicators()).toHaveLength(0)
})
it('reports the live turn as thinking only when its journal says it is reasoning', async () => {
const folded = [userTurn('u1', 'go')]
await render({
messages: folded,
folded,
structuredActivityUi: true,
agentWorking: true,
turnIndicator: { thinking: true, activityText: null }
})
expect(rowProps('u1').turnStatus).toBeNull()
expect(footerProps()).toMatchObject({ thinking: true, workedSeconds: null })
})
it('hands the live row the provider activity copy that outranks its fallbacks', async () => {
const folded = [userTurn('u1', 'go')]
await render({
messages: folded,
folded,
structuredActivityUi: true,
agentWorking: true,
turnIndicator: { thinking: true, activityText: 'Running pnpm test' }
})
expect(footerProps()).toMatchObject({
thinking: true,
activityText: 'Running pnpm test'
})
})
it('keeps the activity copy on the live footer instead of a historical row', async () => {
const folded = [userTurn('u1', 'go'), userTurn('u2', 'again')]
await render({
messages: folded,
folded,
structuredActivityUi: true,
agentWorking: true,
turnIndicator: { thinking: false, activityText: 'Running pnpm test' }
})
expect(rowProps('u1')).not.toHaveProperty('turnActivityText')
expect(rowProps('u2')).not.toHaveProperty('turnActivityText')
expect(footerProps()).toMatchObject({ activityText: 'Running pnpm test' })
})
it('keeps the bridge lane on the three-dot indicator with no turn status', async () => {
const folded = [userTurn('u1', 'go')]
await render({ messages: folded, folded, agentWorking: true })
@@ -294,13 +349,15 @@ describe('MobileNativeChatView', () => {
expect(props.structuredActivityUi).toBe(false)
expect(props.turnStatus).toBeNull()
expect(props.activeTurnIsWorking).toBe(false)
expect(footerProps()).toBeNull()
expect(workingIndicators()).toHaveLength(1)
})
it('settles the finished turn to a tappable duration', async () => {
const folded = [userTurn('u1', 'go'), assistantTurn('a1', 'done')]
await render({ messages: folded, folded, structuredActivityUi: true, agentWorking: true })
expect(rowProps('u1').turnStatus).toMatchObject({ thinking: false, workedSeconds: null })
expect(rowProps('u1').turnStatus).toBeNull()
expect(footerProps()).toMatchObject({ thinking: false, workedSeconds: null })
await update({ messages: folded, folded, structuredActivityUi: true, agentWorking: false })
const settled = rowProps('u1')
expect(settled.turnStatus).toMatchObject({ thinking: false })
@@ -309,6 +366,7 @@ describe('MobileNativeChatView', () => {
)
expect(settled.onToggleTurn).toBeTypeOf('function')
expect(settled.activeTurnIsWorking).toBe(false)
expect(footerProps()).toBeNull()
})
it('hangs no status row on an assistant row', async () => {
@@ -317,6 +375,7 @@ describe('MobileNativeChatView', () => {
expect(rowProps('a1').turnStatus).toBeNull()
// The assistant row still belongs to the live turn, so its tool row stays visible.
expect(rowProps('a1').activeTurnIsWorking).toBe(true)
expect(footerProps()).toMatchObject({ workedSeconds: null })
})
it('does not carry a running turn clock across chat surfaces', async () => {
@@ -331,7 +390,7 @@ describe('MobileNativeChatView', () => {
agentWorking: true,
sendSurfaceId: 'host\0worktree\0tab-a'
})
expect(rowProps('u1').turnStatus).toMatchObject({ startedAt: 1_000 })
expect(footerProps()).toMatchObject({ startedAt: 1_000 })
vi.setSystemTime(12_000)
const secondTab = [userTurn('u2', 'second')]
@@ -343,7 +402,7 @@ describe('MobileNativeChatView', () => {
sendSurfaceId: 'host\0worktree\0tab-b'
})
expect(rowProps('u2').turnStatus).toMatchObject({ startedAt: 12_000 })
expect(footerProps()).toMatchObject({ startedAt: 12_000 })
} finally {
vi.useRealTimers()
}
+14 -4
View File
@@ -13,7 +13,10 @@ import { GestureDetector, GestureHandlerRootView } from 'react-native-gesture-ha
import { ArrowDown, ChevronsDownUp, ChevronsUpDown, Square } from 'lucide-react-native'
import type { AskAnswerSelection, AskPrompt } from '../../../src/shared/native-chat-ask'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status'
import type {
NativeChatLiveTurnIndicator,
NativeChatSettledTurns
} from '../../../src/shared/native-chat-turn-status'
import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-native-chat-view-styles'
import {
@@ -53,6 +56,8 @@ type Props = {
/** Structured lane: per-turn "Working for N" status plus live tool progress,
* replacing the bridge lane's static three-dot working row (desktop parity). */
structuredActivityUi?: boolean
/** What labels the live turn's one indicator row (structured lane only). */
turnIndicator?: NativeChatLiveTurnIndicator | null
/** Structured lane: host-recorded turn timing feeding the per-turn status rows. */
workingStartedAt?: number | null
settledTurns?: NativeChatSettledTurns | null
@@ -136,6 +141,7 @@ export function MobileNativeChatView({
agentWorking,
canStop = agentWorking,
structuredActivityUi = false,
turnIndicator = null,
workingStartedAt,
settledTurns,
onStop,
@@ -259,14 +265,17 @@ export function MobileNativeChatView({
[hasMore, loadingEarlier, onLoadEarlier]
)
// Per-turn "Thinking / Working for N / Worked for N" rows. The structured lane
// owns them; the bridge lane keeps its three-dot indicator.
// Per-turn status rows: one live indicator while the turn runs, then a settled
// "Worked for N" row. The structured lane owns them; the bridge lane keeps its
// three-dot indicator.
const turns = useMobileNativeChatTurnDisclosure({
messages: data,
enabled: structuredActivityUi,
isWorking: agentWorking === true,
workingStartedAt,
settledTurns,
thinking: turnIndicator?.thinking === true,
activityText: turnIndicator?.activityText ?? null,
scopeKey: sendSurfaceId
})
@@ -331,11 +340,12 @@ export function MobileNativeChatView({
) : null
}
ListFooterComponent={
turns.activeTurnIsUnanchored && turns.active ? (
structuredActivityUi && agentWorking && turns.active ? (
<MobileNativeChatTurnStatus
startedAt={turns.active.startedAt}
thinking={turns.active.thinking}
workedSeconds={turns.active.workedSeconds}
activityText={turns.activeActivityText}
/>
) : null
}
@@ -6,7 +6,10 @@ import type {
} from '../../../src/shared/native-chat-ask'
import type { detectAgentPermission } from './mobile-native-chat-permission'
import type { parseAgentQuestion } from './mobile-native-chat-question'
import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status'
import type {
NativeChatLiveTurnIndicator,
NativeChatSettledTurns
} from '../../../src/shared/native-chat-turn-status'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import type { MobileNativeChatPendingMessage } from './use-mobile-native-chat-drafts'
import type { useMobileNativeChatSession } from './use-mobile-native-chat-session'
@@ -29,6 +32,8 @@ export type MobileNativeChatController = {
/** Structured lane: drives the per-turn status row and live tool progress. */
nativeChatStructured: boolean
nativeChatAgentWorking: boolean
/** What labels the live turn's one indicator row; null off the structured lane. */
nativeChatTurnIndicator: NativeChatLiveTurnIndicator | null
/** Structured lane: host-recorded turn timing for the per-turn status rows. */
nativeChatWorkingStartedAt: number | null
nativeChatSettledTurns: NativeChatSettledTurns | null
@@ -0,0 +1,65 @@
import type { MutableRefObject } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send'
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
import { useMobileNativeChatCancelAsk } from './use-mobile-native-chat-cancel-ask'
import { useMobileNativeChatStop } from './use-mobile-native-chat-stop'
import type { MobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
/** The bridge lane's four prompt/interrupt write seams. They share one enable
* gate and chain through the answer seam's `cancelPending`, so a caller cannot
* wire one of them to a different lane or forget to drop in-flight answer
* writes before an Escape. The structured lane answers over RPC instead. */
export function useMobileBridgeChatPromptWrites(args: {
client: RpcClient | null
enabled: boolean
handleRef: MutableRefObject<string | null>
deviceTokenRef: MutableRefObject<string | null>
agentRef: MutableRefObject<string | null>
/** Changes on chat session swap; cancels pending writes when it does. */
sessionId: string | null
streamIdentity: string
onSendError: (message: string) => void
}): {
answerAsk: MobileNativeChatAnswerSend['answerAsk']
cancelAsk: () => Promise<boolean>
respondPermission: (send: string) => Promise<boolean>
stop: () => void
} {
const { client, enabled, handleRef, deviceTokenRef, streamIdentity, onSendError } = args
const { answerAsk, cancelPending } = useMobileNativeChatAnswerSend({
client,
enabled,
handleRef,
deviceTokenRef,
agentRef: args.agentRef,
sessionId: args.sessionId,
streamIdentity,
onSendError
})
const cancelAsk = useMobileNativeChatCancelAsk({
client,
enabled,
handleRef,
deviceTokenRef,
cancelPending,
onSendError
})
const respondPermission = useMobileNativeChatPermissionSend({
client,
enabled,
handleRef,
deviceTokenRef,
onSendError
})
const stop = useMobileNativeChatStop({
client,
enabled,
handleRef,
deviceTokenRef,
streamIdentity,
cancelPending,
onSendError
})
return { answerAsk, cancelAsk, respondPermission, stop }
}
@@ -2,10 +2,7 @@ import { useLayoutEffect, useRef, type MutableRefObject } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import type { MobileNativeChatTab } from './mobile-native-chat-eligibility'
import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send'
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss'
import { useMobileNativeChatCancelAsk } from './use-mobile-native-chat-cancel-ask'
import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search'
import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send'
@@ -14,10 +11,10 @@ import { useMobileNativeChatSessionOptionController } from './use-mobile-native-
import { useMobileNativeChatSessionLane } from './use-mobile-native-chat-session-lane'
import { useMobileStructuredNativeChatSendBridge } from './use-mobile-structured-native-chat-send-bridge'
import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts'
import { useMobileNativeChatStop } from './use-mobile-native-chat-stop'
import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes'
import { useThrottledLatestValue } from './use-throttled-latest-value'
import type { MobileNativeChatController } from './mobile-native-chat-controller-contract'
import { useMobileBridgeChatPromptWrites } from './use-mobile-bridge-chat-prompt-writes'
import { useMobileNativeChatActiveResolution } from './use-mobile-native-chat-active-resolution'
export type { MobileNativeChatController } from './mobile-native-chat-controller-contract'
@@ -171,42 +168,19 @@ export function useMobileNativeChatController(args: {
? client != null && activeChatSessionId != null && connState === 'connected'
: nativeChatInputLeaseReady && connState === 'connected'
const { answerAsk: handleNativeChatAnswerAsk, cancelPending: cancelNativeChatAnswer } =
useMobileNativeChatAnswerSend({
client,
enabled: inputSendable && !activeChatStructured,
handleRef: activeHandleRef,
deviceTokenRef,
agentRef: activeChatAgentRef,
sessionId: activeChatSessionId,
streamIdentity,
onSendError
})
const handleNativeChatCancelAsk = useMobileNativeChatCancelAsk({
client,
enabled: inputSendable && !activeChatStructured,
handleRef: activeHandleRef,
deviceTokenRef,
cancelPending: cancelNativeChatAnswer,
onSendError
})
const legacyHandleNativeChatRespondPermission = useMobileNativeChatPermissionSend({
client,
enabled: inputSendable && !activeChatStructured,
handleRef: activeHandleRef,
deviceTokenRef,
onSendError
})
const handleNativeChatStop = useMobileNativeChatStop({
const {
answerAsk: handleNativeChatAnswerAsk,
cancelAsk: handleNativeChatCancelAsk,
respondPermission: legacyHandleNativeChatRespondPermission,
stop: handleNativeChatStop
} = useMobileBridgeChatPromptWrites({
client,
enabled: inputSendable && !activeChatStructured,
handleRef: activeHandleRef,
deviceTokenRef,
agentRef: activeChatAgentRef,
sessionId: activeChatSessionId,
streamIdentity,
cancelPending: cancelNativeChatAnswer,
onSendError
})
@@ -299,6 +273,7 @@ export function useMobileNativeChatController(args: {
/** Structured lane: drives the per-turn status row and live tool progress. */
nativeChatStructured: activeChatStructured,
nativeChatAgentWorking,
nativeChatTurnIndicator: activeChatStructured ? structuredNativeChat.turnIndicator : null,
nativeChatWorkingStartedAt: activeChatStructured ? structuredNativeChat.workingStartedAt : null,
nativeChatSettledTurns: activeChatStructured ? structuredNativeChat.settledTurns : null,
nativeChatCanStop: activeChatStructured
@@ -28,6 +28,8 @@ export function useMobileNativeChatTurnDisclosure({
isWorking,
workingStartedAt,
settledTurns,
thinking = false,
activityText = null,
scopeKey
}: {
messages: readonly NativeChatMessage[]
@@ -36,12 +38,16 @@ export function useMobileNativeChatTurnDisclosure({
workingStartedAt?: number | null
/** Host-recorded durations; they outrank whatever this client observed. */
settledTurns?: NativeChatSettledTurns | null
/** Whether the turn is reasoning right now, derived from its journal content. */
thinking?: boolean
/** What the provider says the live turn is doing; outranks the other labels. */
activityText?: string | null
/** Host/worktree/tab identity for timing and disclosure isolation. */
scopeKey: string
}): {
active: NativeChatTurnStatus | null
/** True when the live turn has no user message to hang its status row under. */
activeTurnIsUnanchored: boolean
/** The live turn's provider activity copy, for the footer row. */
activeActivityText: string | null
onToggleTurn: (turnKey: string) => void
resolveRow: (index: number, message: NativeChatMessage) => MobileNativeChatTurnRow
} {
@@ -51,6 +57,7 @@ export function useMobileNativeChatTurnDisclosure({
isWorking,
workingStartedAt,
settledTurns,
thinking,
scopeKey
})
const [expandedTurns, setExpandedTurns] = useState<{
@@ -93,17 +100,16 @@ export function useMobileNativeChatTurnDisclosure({
}, [enabled, messages])
const { active, activeTurnKey, completedByTurn } = turnStatuses
const activeActivityText = enabled && isWorking ? (activityText ?? null) : null
const resolveRow = useCallback(
(index: number, message: NativeChatMessage): MobileNativeChatTurnRow => {
const turnKey = turnKeys[index]
const turnStatus =
!enabled || message.role !== 'user'
? null
: turnKey === activeTurnKey
? active
: turnKey
? (completedByTurn[turnKey] ?? null)
: null
: turnKey
? (completedByTurn[turnKey] ?? null)
: null
return {
turnStatus,
turnExpanded: turnKey ? expandedTurnIds.has(turnKey) : false,
@@ -120,15 +126,14 @@ export function useMobileNativeChatTurnDisclosure({
(turnKey === undefined && activeTurnKey === MOBILE_UNANCHORED_TURN_KEY))
}
},
[turnKeys, enabled, activeTurnKey, active, completedByTurn, expandedTurnIds, isWorking]
[turnKeys, enabled, activeTurnKey, completedByTurn, expandedTurnIds, isWorking]
)
return {
active,
activeActivityText,
/** Stable for a given chat scope, so it never disturbs a row's memo. */
onToggleTurn: toggleExpandedTurn,
activeTurnIsUnanchored:
enabled && active != null && activeTurnKey === MOBILE_UNANCHORED_TURN_KEY,
resolveRow
}
}
@@ -1,7 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
nativeChatTurnHasResponse,
reduceNativeChatTurnTiming,
selectNativeChatTurnStatuses,
type NativeChatSettledTurns,
@@ -27,6 +26,7 @@ export function useMobileNativeChatTurnStatus({
isWorking,
workingStartedAt,
settledTurns,
thinking = false,
scopeKey
}: {
messages: readonly NativeChatMessage[]
@@ -35,6 +35,8 @@ export function useMobileNativeChatTurnStatus({
workingStartedAt?: number | null
/** Host-recorded durations; they outrank whatever this client observed. */
settledTurns?: NativeChatSettledTurns | null
/** Whether the turn is reasoning right now, derived from its journal content. */
thinking?: boolean
/** Host/worktree/tab identity. Timings never carry across chat surfaces. */
scopeKey: string
}): {
@@ -45,7 +47,6 @@ export function useMobileNativeChatTurnStatus({
const latestUserIndex = enabled
? messages.findLastIndex((message) => message.role === 'user')
: -1
const hasCurrentTurnResponse = enabled && nativeChatTurnHasResponse(messages, latestUserIndex)
const latestUserId = latestUserIndex !== -1 ? (messages[latestUserIndex]?.id ?? null) : null
const activeTurnKey = latestUserId ?? MOBILE_UNANCHORED_TURN_KEY
const [scopedTiming, setScopedTiming] = useState<ScopedTurnTiming>(() => ({
@@ -95,6 +96,7 @@ export function useMobileNativeChatTurnStatus({
// turn re-renders ~20x/s. Without this, every settled turn's row gets fresh
// props each tick and the memoized message rows all re-render.
const turnIsWorking = enabled && isWorking
const turnIsThinking = enabled && thinking
const settledByTurn = enabled ? (settledTurns ?? undefined) : undefined
const statuses = useMemo(
() =>
@@ -102,17 +104,10 @@ export function useMobileNativeChatTurnStatus({
activeTurnKey,
isWorking: turnIsWorking,
workingStartedAt,
hasCurrentTurnResponse,
thinking: turnIsThinking,
settledByTurn
}),
[
timingByTurn,
activeTurnKey,
turnIsWorking,
workingStartedAt,
hasCurrentTurnResponse,
settledByTurn
]
[timingByTurn, activeTurnKey, turnIsWorking, workingStartedAt, turnIsThinking, settledByTurn]
)
return { ...statuses, activeTurnKey }
}
@@ -11,10 +11,12 @@ import {
import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection'
import { hasUnansweredStructuredAgentSessionDispatch } from '../../../src/shared/structured-agent-session-projection'
import {
activeStructuredAgentSessionTurnId,
hasUnansweredStructuredAgentSessionDispatch
} from '../../../src/shared/structured-agent-session-projection'
isStructuredAgentSessionThinking
} from '../../../src/shared/structured-agent-session-live-turn'
import { selectStructuredAgentTurnActivity } from '../../../src/shared/native-chat-turn-activity'
import {
pendingStructuredApproval,
pendingStructuredQuestion,
@@ -31,6 +33,7 @@ import type { RpcClient } from '../transport/rpc-client'
import type { MobileChatPermission } from './mobile-native-chat-permission'
import type { MobileChatQuestion } from './mobile-native-chat-question'
import type { MobileNativeChatSession } from './use-mobile-native-chat-session'
import type { NativeChatLiveTurnIndicator } from '../../../src/shared/native-chat-turn-status'
import { useMobileStructuredAgentState } from './use-mobile-structured-agent-state'
import { useMobileStructuredPromptResponses } from './use-mobile-structured-prompt-responses'
import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options'
@@ -43,6 +46,8 @@ type StructuredMobileSession = ReturnType<typeof useMobileStructuredAgentOptions
session: MobileNativeChatSession
isWorking: boolean
turnId: string | null
/** What labels the live turn's one indicator row. */
turnIndicator: NativeChatLiveTurnIndicator
sendWithOutcome: (
text: string,
images?: string[],
@@ -270,6 +275,12 @@ export function useMobileStructuredAgentSession(args: {
)
const turnId = activeStructuredAgentSessionTurnId(state.items)
const turnTiming = useMobileStructuredAgentTurnTiming(state, turnId)
const activityText =
selectStructuredAgentTurnActivity(state.items, turnId, state.activity)?.text ?? null
const thinking = isStructuredAgentSessionThinking(state.items)
// Stable while the readings hold, so a streaming turn does not re-render the
// whole chat surface on every journal batch.
const turnIndicator = useMemo(() => ({ thinking, activityText }), [thinking, activityText])
const status = state.status === 'idle' ? 'idle' : state.status
const approvalPrompt = useMemo(
() => state.items.find(pendingStructuredApproval) ?? null,
@@ -296,6 +307,7 @@ export function useMobileStructuredAgentSession(args: {
turnId !== null ||
hasUnansweredStructuredAgentSessionDispatch(state.submissions, state.fence),
turnId,
turnIndicator,
...turnTiming,
sendWithOutcome,
cancel,
@@ -0,0 +1,138 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types'
import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire'
import type { RpcClient } from '../transport/rpc-client'
import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
function journalItem(
sequence: number,
body: AgentJournalRenderItem['body']
): AgentJournalRenderItem {
return { itemId: `item-${sequence}`, revision: 1, sequence, observedAt: sequence, body }
}
function snapshot(items: AgentJournalRenderItem[], fence: number): AgentSessionSubscribeEvent {
const newest = items.length
return {
type: 'snapshot',
sessionId: 'session-1',
fence,
page: {
sessionId: 'session-1',
epoch: 'epoch-1',
fence,
direction: 'tail',
items,
removedItemIds: [],
submissions: [],
window: {
oldest: { epoch: 'epoch-1', sequence: 1 },
newest: { epoch: 'epoch-1', sequence: newest },
nextCursor: { epoch: 'epoch-1', sequence: newest + 1 }
},
liveCursor: { epoch: 'epoch-1', sequence: newest },
hasOlder: false,
hasNewer: false
}
} as AgentSessionSubscribeEvent
}
/** What the one live indicator row reads, resolved off the session journal. */
describe('useMobileStructuredAgentSession turn indicator', () => {
let renderer: ReactTestRenderer | null = null
let hook: ReturnType<typeof useMobileStructuredAgentSession> | null = null
let listener: ((value: unknown) => void) | null = null
const sendRequest = vi.fn(async (method: string) => ({
ok: true,
result:
method === 'agentSession.options'
? {
models: [{ id: 'gpt-fast', label: 'GPT Fast', isDefault: true, efforts: [] }],
current: { model: 'gpt-fast' }
}
: {},
_meta: { runtimeId: 'r1' }
}))
const subscribe = vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => {
listener = onData
return vi.fn()
})
const client = { sendRequest, subscribe } as unknown as RpcClient
// Stable across renders: a fresh callback would re-run the hold/subscribe effect
// and release the session out from under the test.
const onSendError = vi.fn()
function Harness(): null {
hook = useMobileStructuredAgentSession({
client,
sessionId: 'session-1',
sourceIdentity: 'host-a\0workspace-a',
enabled: true,
connected: true,
agent: 'codex',
onSendError
} as never)
return null
}
beforeEach(() => {
vi.clearAllMocks()
listener = null
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
hook = null
})
const runningTurn = journalItem(1, { kind: 'turn', turnId: 'turn-1', state: 'running' })
const reasoning = journalItem(2, {
kind: 'message',
role: 'reasoning',
blocks: [{ type: 'text', text: 'Weighing two approaches' }]
})
it('reads the live turn as reasoning while reasoning is its newest content', async () => {
act(() => {
renderer = create(createElement(Harness))
})
await vi.waitFor(() => expect(listener).not.toBeNull())
act(() => {
listener?.(snapshot([runningTurn, reasoning], 3))
})
expect(hook?.turnIndicator).toEqual({ thinking: true, activityText: null })
})
it('hands the row the provider copy once real content ends the reasoning', async () => {
act(() => {
renderer = create(createElement(Harness))
})
await vi.waitFor(() => expect(listener).not.toBeNull())
act(() => {
listener?.(
snapshot(
[
runningTurn,
reasoning,
journalItem(3, {
kind: 'tool-call',
name: 'shell',
input: { command: 'pnpm lint' },
state: 'running'
}),
journalItem(4, { kind: 'status', text: 'Updating the plan' })
],
3
)
)
})
expect(hook?.turnIndicator).toEqual({ thinking: false, activityText: 'Updating the plan' })
})
})
+1
View File
@@ -29,6 +29,7 @@
"test": "node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts",
"test:skill-sharing:release": "vitest run --config config/vitest.config.ts src/main/skills src/main/runtime/rpc/methods/skills.test.ts src/relay/skill-install-handler.test.ts src/shared/skill-bundle-install-contract.test.ts src/shared/skill-install-contract.test.ts src/shared/skill-install-failure.test.ts src/shared/skill-package-manifest.test.ts",
"test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs",
"capture:agent-transcript": "node config/scripts/ensure-native-runtime.mjs --runtime=node && node config/scripts/capture-agent-pty-transcript.mjs",
"check:reliability-gates": "node config/scripts/check-reliability-gates.mjs",
"check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs",
"check:ts-nocheck-ratchet": "node config/scripts/check-ts-nocheck-ratchet.mjs",
@@ -569,8 +569,11 @@ describe('Claude structured journal translation', () => {
translator.handle(message('assistant', 'assistant-thinking', [{ type: 'thinking', thinking }]))
expect(state.items.at(-1)?.body).toEqual({
kind: 'status',
text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text
kind: 'message',
role: 'reasoning',
blocks: [
{ type: 'text', text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text }
]
})
})
@@ -180,8 +180,11 @@ export function createClaudeJournalTranslator(
const thinking = claudeThinkingText(outputEnvelope)
if (thinking) {
deps.sink.appendItem(claudeThinkingIdentity(envelope.sessionId, envelope.uuid), {
kind: 'status',
text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text
kind: 'message',
role: 'reasoning',
blocks: [
{ type: 'text', text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text }
]
})
changed = true
}
@@ -22,11 +22,16 @@ describe('plan document translation', () => {
expect(
codexItemBody({ id: 'r', type: 'reasoning', summary: ['Thinking through the problem.'] })
).toEqual({
kind: 'status',
text: 'Thinking through the problem.'
kind: 'message',
role: 'reasoning',
blocks: [{ type: 'text', text: 'Thinking through the problem.' }]
})
expect(codexStreamingJournalItem({ id: 'r', type: 'reasoning' }, 'Thinking…')).toEqual({
body: { kind: 'status', text: 'Thinking…' },
body: {
kind: 'message',
role: 'reasoning',
blocks: [{ type: 'text', text: 'Thinking…' }]
},
handled: true
})
})
@@ -10,6 +10,7 @@ import {
codexItemIdentity,
codexJournalItem,
codexMessageBlocks,
codexStreamingJournalItem,
CodexTurnOrdinals,
MAX_CODEX_TURN_ORDINAL_BYTES,
MAX_CODEX_TURN_ORDINAL_ENTRIES,
@@ -601,12 +602,18 @@ describe('codex item bodies', () => {
body: { kind: 'status', text, presentation: 'plan-document' },
handled: true
})
// A plan is a durable artifact, so it must never read as the model reasoning now.
expect(codexItemBody({ type: 'plan', id: 'plan-document', text })).not.toMatchObject({
kind: 'message',
role: 'reasoning'
})
})
it('renders reasoning as status and exposes an unknown item as a provider frame', () => {
it('renders reasoning as a typed message and exposes an unknown item as a provider frame', () => {
expect(codexItemBody({ type: 'reasoning', id: 'r', text: 'thinking' })).toEqual({
kind: 'status',
text: 'thinking'
kind: 'message',
role: 'reasoning',
blocks: [{ type: 'text', text: 'thinking' }]
})
expect(codexItemBody({ type: 'reasoning', id: 'r' })).toBeNull()
expect(codexItemBody({ type: 'agentMessage', id: 'm', text: '' })).toBeNull()
@@ -617,6 +624,15 @@ describe('codex item bodies', () => {
})
})
it('keeps non-reasoning item streams as status activity', () => {
expect(
codexStreamingJournalItem({ type: 'somethingCodexAddedLater', id: 'x' }, 'still working')
).toEqual({
body: { kind: 'status', text: 'still working' },
handled: true
})
})
it('gives an mcp tool call a typed body with its own arguments as input', () => {
expect(
codexItemBody({
@@ -843,7 +859,11 @@ describe('codex item bodies', () => {
summary: ['first', 'second'],
content: [{ text: 'fallback' }]
})
).toEqual({ kind: 'status', text: 'first\nsecond' })
).toEqual({
kind: 'message',
role: 'reasoning',
blocks: [{ type: 'text', text: 'first\nsecond' }]
})
})
it('refuses a value that is not a thread item at all', () => {
@@ -85,6 +85,10 @@ export type CodexJournalItem = {
handled: boolean
}
function reasoningMessageBody(text: string): AgentJournalItemBody {
return { kind: 'message', role: 'reasoning', blocks: [{ type: 'text', text }] }
}
function commandItem(item: CodexThreadItem): CodexJournalItem {
const output = readFirstString(item, ['aggregatedOutput', 'aggregated_output'])
const bounded = output === null ? null : boundInlineText(output, DEFAULT_JOURNAL_PAYLOAD_LIMITS)
@@ -272,7 +276,7 @@ export function codexJournalItem(item: CodexThreadItem): CodexJournalItem {
handled: true
}
}
if (item.type === 'reasoning' || item.type === 'plan') {
if (item.type === 'reasoning') {
const text =
readTextContent(item, 'text') ??
readTextContent(item, 'summary') ??
@@ -281,7 +285,7 @@ export function codexJournalItem(item: CodexThreadItem): CodexJournalItem {
body:
text === null
? null
: { kind: 'status', text: boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text },
: reasoningMessageBody(boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text),
handled: true
}
}
@@ -331,5 +335,11 @@ export function codexStreamingJournalItem(item: CodexThreadItem, text: string):
}
}
const bounded = boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS)
return { body: { kind: 'status', text: bounded.text }, handled: true }
return {
body:
item.type === 'reasoning'
? reasoningMessageBody(bounded.text)
: { kind: 'status', text: bounded.text },
handled: true
}
}
@@ -799,8 +799,9 @@ describe('codex journal translation', () => {
const reduced = new Map(tap.rows.map((row) => [row.key, row.body]))
expect(reduced.get('orca:codex-item%3Athread-abc%3Ar-1')).toEqual({
kind: 'status',
text: 'thinking'
kind: 'message',
role: 'reasoning',
blocks: [{ type: 'text', text: 'thinking' }]
})
expect(reduced.get('orca:codex-item%3Athread-abc%3Apatch-1')).toMatchObject({
kind: 'diff',
@@ -0,0 +1,63 @@
import { afterEach, describe, expect, it } from 'vitest'
import { HeadlessEmulator } from './headless-emulator'
// Why this suite: collectHeadlessOscLinkRanges skips its per-cell scan when
// xterm holds no OSC 8 registration. That skip is only safe if it can never
// fire while a link is reachable, so each case below pins one way it could.
let emulator: HeadlessEmulator | undefined
const link = (uri: string, text: string): string => `\x1b]8;;${uri}\x1b\\${text}\x1b]8;;\x1b\\`
afterEach(() => {
emulator?.dispose()
emulator = undefined
})
describe('headless OSC link ranges', () => {
it('finds a link written into the buffer', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write(`before ${link('https://example.com/a', 'CLICK')} after`)
const ranges = emulator.getSnapshot().oscLinks ?? []
expect(ranges).toHaveLength(1)
expect(ranges[0]).toMatchObject({ row: 0, uri: 'https://example.com/a' })
})
it('returns nothing for a buffer that never emitted a link', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('plain output with no hyperlink\r\n'.repeat(50))
expect(emulator.getSnapshot().oscLinks).toEqual([])
})
// The dangerous case: restored ranges are seeded without xterm registering
// anything, so an early-out keyed only on the registry would drop them.
it('still maps restored ranges when the buffer itself has no link', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('restored row')
const restored = { row: 0, startCol: 0, endCol: 4, uri: 'https://example.com/restored' }
emulator.setRestoredOscLinks([restored])
expect(emulator.getSnapshot().oscLinks).toEqual([restored])
})
it('finds links far down a long scrollback, not just the visible screen', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24, scrollback: 5_000 })
await emulator.write(`${link('https://example.com/top', 'TOP')}\r\n`)
await emulator.write('filler\r\n'.repeat(2_000))
const ranges = emulator.getSnapshot({ scrollbackRows: 5_000 }).oscLinks ?? []
expect(ranges.map((range) => range.uri)).toContain('https://example.com/top')
})
it('keeps every distinct link when several are present', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write(
`${link('https://example.com/1', 'ONE')} ${link('https://example.com/2', 'TWO')}`
)
const uris = (emulator.getSnapshot().oscLinks ?? []).map((range) => range.uri)
expect(uris).toContain('https://example.com/1')
expect(uris).toContain('https://example.com/2')
})
})
+23 -4
View File
@@ -1,10 +1,14 @@
import type { Terminal } from '@xterm/headless'
import type { IBufferCell, IBufferLine, Terminal } from '@xterm/headless'
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
type TerminalWithOscLinks = Terminal & {
_core?: {
_oscLinkService?: {
getLinkData: (linkId: number) => { uri?: string } | undefined
// Why read it: xterm registers every OSC 8 id here, so an empty registry
// proves the buffer holds no hyperlink and the per-cell scan can be skipped.
// Optional because it is private — an xterm that renames it just scans.
_dataByLinkId?: { size?: number }
}
}
}
@@ -14,6 +18,11 @@ type CellWithOscLink = {
hasExtendedAttrs?: () => boolean
}
/** True when xterm holds no OSC 8 registration at all, so no cell can carry one. */
function hasNoRegisteredOscLinks(service: { _dataByLinkId?: { size?: number } }): boolean {
return service._dataByLinkId?.size === 0
}
export function collectHeadlessOscLinkRanges(
terminal: Terminal,
scrollbackRows: number | undefined,
@@ -26,9 +35,19 @@ export function collectHeadlessOscLinkRanges(
return []
}
const buffer = terminal.buffer.active
// Why before the scan: the walk below reads every cell of every row, and a
// session that never emitted a hyperlink — the overwhelming majority — would
// pay that for a guaranteed-empty result. `restoredLinks` still needs mapping.
if (hasNoRegisteredOscLinks(service) && restoredLinks.length === 0) {
return []
}
const startRow =
scrollbackRows === undefined ? 0 : Math.max(0, buffer.length - terminal.rows - scrollbackRows)
const ranges: TerminalOscLinkRange[] = []
// Why one cell for the whole walk: xterm's getCell allocates a fresh CellData
// per call unless handed a target, which is a per-cell allocation across the
// entire scrollback. See the IBufferLine.getCell docs.
const scratchCell = buffer.getNullCell()
for (let row = startRow; row < buffer.length; row += 1) {
const line = buffer.getLine(row)
if (!line) {
@@ -38,7 +57,7 @@ export function collectHeadlessOscLinkRanges(
let currentUrlId = 0
let currentStart = -1
for (let col = 0; col <= lineLength; col += 1) {
const urlId = col < lineLength ? getOscLinkIdAtCell(line, col) : 0
const urlId = col < lineLength ? getOscLinkIdAtCell(line, col, scratchCell) : 0
if (urlId === currentUrlId) {
continue
}
@@ -83,8 +102,8 @@ function dedupeOscLinkRanges(ranges: TerminalOscLinkRange[]): TerminalOscLinkRan
})
}
function getOscLinkIdAtCell(line: { getCell: (col: number) => unknown }, col: number): number {
const cell = line.getCell(col) as CellWithOscLink | undefined
function getOscLinkIdAtCell(line: IBufferLine, col: number, scratchCell: IBufferCell): number {
const cell = line.getCell(col, scratchCell) as (IBufferCell & CellWithOscLink) | undefined
// Why: OSC link IDs live in extended cell attrs; missing attrs means no link.
return cell?.hasExtendedAttrs?.() && cell.extended?.urlId ? cell.extended.urlId : 0
}
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it } from 'vitest'
import type { PairingOffer } from '../../shared/pairing'
import {
advanceRuntimeEnvironmentCapabilityIncarnation,
@@ -17,7 +17,6 @@ describe('runtime environment capability evidence', () => {
it('accepts evidence by dispatch order instead of completion order', () => {
const older = captureRuntimeEnvironmentCapabilityEvidence('env', pairing())
const newer = captureRuntimeEnvironmentCapabilityEvidence('env', pairing())
const pause = vi.fn()
expect(
applyRuntimeEnvironmentCapabilityVerdict({
@@ -30,12 +29,10 @@ describe('runtime environment capability evidence', () => {
applyRuntimeEnvironmentCapabilityVerdict({
evidence: older,
verdict: 'absent',
runtimeId: 'runtime-old',
onAbsent: pause
runtimeId: 'runtime-old'
})
).toBe(false)
expect(pause).not.toHaveBeenCalled()
expect(isRuntimeEnvironmentCapabilityPaused('env')).toBe(false)
})
@@ -68,8 +68,6 @@ export function applyRuntimeEnvironmentCapabilityVerdict(args: {
evidence: RuntimeEnvironmentCapabilityEvidence
verdict: RuntimeEnvironmentCapabilityVerdict
runtimeId: string
onCapable?: () => void
onAbsent?: () => void
}): boolean {
const state = stateFor(args.evidence.environmentId)
if (
@@ -83,11 +81,6 @@ export function applyRuntimeEnvironmentCapabilityVerdict(args: {
verdict: args.verdict,
runtimeId: args.runtimeId
}
if (args.verdict === 'capable') {
args.onCapable?.()
} else {
args.onAbsent?.()
}
return true
}
@@ -20,6 +20,8 @@ import { verifyAndAddRuntimeEnvironmentFromPairingCode } from './runtime-environ
import { clearRuntimeEnvironmentCapabilityEvidence } from './runtime-environment-capability-evidence'
import {
closeRemoteRuntimeRequestConnection,
getRuntimeEnvironmentStatusOwner,
getRuntimeEnvironmentStatusSnapshots,
retryRemoteRuntimeSharedControlConnectionNow
} from './runtime-environment-request-connections'
import {
@@ -29,7 +31,6 @@ import {
} from './runtime-environment-manual-disconnect'
import {
callRuntimeEnvironment,
clearSharedControlSupport,
getRuntimeEnvironmentStatus
} from './runtime-environment-transport-routing'
@@ -60,6 +61,9 @@ export function registerRuntimeEnvironmentConnectivityHandlers({
getUserDataPath,
invalidateTransport
}: ConnectivityHandlerOptions): void {
ipcMain.handle('runtimeEnvironments:getStatusSnapshots', () =>
getRuntimeEnvironmentStatusSnapshots()
)
ipcMain.handle('runtimeEnvironments:list', () =>
listEnvironments(getUserDataPath()).map(redactRuntimeEnvironment)
)
@@ -80,6 +84,12 @@ export function registerRuntimeEnvironmentConnectivityHandlers({
const result = await verifyAndAddRuntimeEnvironmentFromPairingCode(getUserDataPath(), args)
if (result.ok) {
clearRuntimeEnvironmentManualDisconnect(result.environment.id)
getRuntimeEnvironmentStatusOwner(getUserDataPath(), result.environment.id).acceptVerified({
id: 'status.get',
ok: true,
result: result.runtimeStatus,
_meta: { runtimeId: result.runtimeStatus.runtimeId }
})
}
return result
}
@@ -121,6 +131,8 @@ export function registerRuntimeEnvironmentConnectivityHandlers({
markRuntimeEnvironmentManuallyDisconnected(environment.id)
invalidateTransport(environment.id)
closeLegacySelectorTransport(args.selector, environment.id)
// Retain disconnected evidence for renderers that missed the teardown event.
getRuntimeEnvironmentStatusOwner(getUserDataPath(), environment.id)
return { disconnected: redactRuntimeEnvironment(environment) }
}
)
@@ -132,7 +144,9 @@ export function registerRuntimeEnvironmentConnectivityHandlers({
): Promise<RuntimeRpcResponse<RuntimeStatus>> => {
const environment = resolveEnvironment(getUserDataPath(), args.selector)
clearRuntimeEnvironmentManualDisconnect(environment.id)
return getRuntimeEnvironmentStatus(getUserDataPath(), environment.id, args.timeoutMs)
return getRuntimeEnvironmentStatus(getUserDataPath(), environment.id, args.timeoutMs, {
reconnect: true
})
}
)
ipcMain.handle(
@@ -156,7 +170,6 @@ function closeLegacySelectorTransport(selector: string, environmentId: string):
return
}
closeRemoteRuntimeRequestConnection(selector)
clearSharedControlSupport(selector)
}
function registerPassiveStatusHandler(getUserDataPath: () => string): void {
@@ -1,3 +1,5 @@
import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections'
vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } }))
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -20,14 +22,17 @@ vi.mock('../../shared/remote-runtime-client', () => ({
sendRemoteRuntimeRequest: sendRemoteRuntimeRequestMock
}))
vi.mock('./runtime-environment-request-connections', () => ({
sendRemoteRuntimeConnectionRequest: vi.fn(),
sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock,
reconnectRemoteRuntimeSharedControlConnection: vi.fn(),
retryRemoteRuntimeSharedControlConnectionNow: vi.fn(),
ensureRemoteRuntimeSharedControlConnection: vi.fn(),
pauseRemoteRuntimeSharedControlRetry: vi.fn()
}))
vi.mock('./runtime-environment-request-connections', async () => {
const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness')
return withRuntimeStatusOwners({
sendRemoteRuntimeConnectionRequest: vi.fn(),
sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock,
reconnectRemoteRuntimeSharedControlConnection: vi.fn(),
retryRemoteRuntimeSharedControlConnectionNow: vi.fn(),
ensureRemoteRuntimeSharedControlConnection: vi.fn(),
pauseRemoteRuntimeSharedControlRetry: vi.fn()
})
})
import {
callRuntimeEnvironment,
@@ -55,6 +60,7 @@ describe('federated read RPC transport routing', () => {
})
afterEach(() => {
resetRuntimeEnvironmentStatusOwners()
rmSync(userDataPath, { recursive: true, force: true })
})
@@ -9,6 +9,7 @@ export const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [
'runtimeEnvironments:retryControlConnection',
'runtimeEnvironments:prepareBrowserClientHostPlacement',
'runtimeEnvironments:getStatus',
'runtimeEnvironments:getStatusSnapshots',
'runtimeEnvironments:call',
'runtimeEnvironments:subscribe',
'runtimeEnvironments:unsubscribe'
@@ -47,9 +47,9 @@ describe('runtime environment shared-control connection cache', () => {
applyRuntimeEnvironmentCapabilityVerdict({
evidence: absent,
verdict: 'absent',
runtimeId: 'runtime-test',
onAbsent: () => pauseRemoteRuntimeSharedControlRetry(ENVIRONMENT_ID)
runtimeId: 'runtime-test'
})
pauseRemoteRuntimeSharedControlRetry(ENVIRONMENT_ID)
expect(getRemoteRuntimeSharedControlDiagnostics(ENVIRONMENT_ID)?.state).toBe('closed')
await delay(400)
expect(server.connectionCount()).toBe(1)
@@ -58,12 +58,10 @@ describe('runtime environment shared-control connection cache', () => {
applyRuntimeEnvironmentCapabilityVerdict({
evidence: capable,
verdict: 'capable',
runtimeId: 'runtime-test',
onCapable: () => {
ensureRemoteRuntimeSharedControlConnection(ENVIRONMENT_ID, server.pairing)
reconnectRemoteRuntimeSharedControlConnection(ENVIRONMENT_ID)
}
runtimeId: 'runtime-test'
})
ensureRemoteRuntimeSharedControlConnection(ENVIRONMENT_ID, server.pairing)
reconnectRemoteRuntimeSharedControlConnection(ENVIRONMENT_ID)
await waitFor(() => server.connectionCount() === 2)
})
@@ -119,9 +117,9 @@ describe('runtime environment shared-control connection cache', () => {
applyRuntimeEnvironmentCapabilityVerdict({
evidence,
verdict: 'absent',
runtimeId: 'runtime-test',
onAbsent: () => pauseRemoteRuntimeSharedControlRetry(ENVIRONMENT_ID)
runtimeId: 'runtime-test'
})
pauseRemoteRuntimeSharedControlRetry(ENVIRONMENT_ID)
expect(getRemoteRuntimeSharedControlDiagnostics(ENVIRONMENT_ID)?.state).toBe('reconnecting')
await waitFor(() => server.connectionCount() === 2)
@@ -1,4 +1,9 @@
import type { PairingOffer } from '../../shared/pairing'
import { resolveEnvironment } from '../../shared/runtime-environment-store'
import { getPreferredPairingOffer } from '../../shared/runtime-environments'
import type { RuntimeHostStatusOwner } from '../../shared/runtime-host-status-owner'
import type { RuntimeStatus } from '../../shared/runtime-types'
import { createRuntimeEnvironmentStatusOwner } from './runtime-environment-status-owner'
import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/protocol-version'
import type {
RuntimeOrchestrationEnvelope,
@@ -30,6 +35,56 @@ type CachedSharedControlConnection = {
const requestConnections = new Map<string, CachedRuntimeConnection>()
const sharedControlConnections = new Map<string, CachedSharedControlConnection>()
const statusOwners = new Map<string, { key: string; owner: RuntimeHostStatusOwner }>()
export function getRuntimeEnvironmentStatusOwner(
userDataPath: string,
selector: string
): RuntimeHostStatusOwner {
const environment = resolveEnvironment(userDataPath, selector)
const pairing = getPreferredPairingOffer(environment)
const key = `${userDataPath}\0${environment.pairingRevision ?? environment.createdAt}\0${getPairingKey(pairing)}`
let cached = statusOwners.get(environment.id)
if (!cached || cached.key !== key || cached.owner.read().retired) {
if (cached) {
closeRemoteRuntimeRequestConnection(environment.id)
}
const owner = createRuntimeEnvironmentStatusOwner(userDataPath, environment, {
isReady: () => getRemoteRuntimeSharedControlDiagnostics(environment.id)?.state === 'ready',
request: (signal) =>
sendRemoteRuntimeSharedControlRequest<RuntimeStatus>(
environment.id,
pairing,
'status.get',
undefined,
15_000,
undefined,
signal
),
establish: () => {
ensureRemoteRuntimeSharedControlConnection(environment.id, pairing)
reconnectRemoteRuntimeSharedControlConnection(environment.id)
},
pause: () => pauseRemoteRuntimeSharedControlRetry(environment.id)
})
cached = { key, owner }
statusOwners.set(environment.id, cached)
if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) {
owner.dispose()
}
}
return cached.owner
}
export function resetRuntimeEnvironmentStatusOwners(): void {
for (const id of statusOwners.keys()) {
closeRemoteRuntimeRequestConnection(id)
}
}
export function getRuntimeEnvironmentStatusSnapshots() {
return [...statusOwners.values()].map(({ owner }) => owner.read())
}
export function sendRemoteRuntimeConnectionRequest<TResult>(
environmentId: string,
@@ -56,6 +111,9 @@ export function sendRemoteRuntimeConnectionRequest<TResult>(
}
export function closeRemoteRuntimeRequestConnection(environmentId: string): void {
const status = statusOwners.get(environmentId)
statusOwners.delete(environmentId)
status?.owner.dispose()
const cached = requestConnections.get(environmentId)
requestConnections.delete(environmentId)
cached?.connection.close()
@@ -166,6 +224,16 @@ function getSharedControlConnection(
transportGeneration,
diagnostics
})
statusOwners
.get(environmentId)
?.owner.connectionChanged(
diagnostics.state === 'ready'
? 'ready'
: diagnostics.state === 'closed' || diagnostics.state === 'reconnecting'
? 'disconnected'
: 'connecting',
diagnostics
)
}
})
}
@@ -1,39 +1,23 @@
import {
ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES,
REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY
} from '../../shared/protocol-version'
import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client'
import { markEnvironmentUsed } from '../../shared/runtime-environment-store'
import type {
getPreferredPairingOffer,
KnownRuntimeEnvironment
} from '../../shared/runtime-environments'
import type { RuntimeStatus } from '../../shared/runtime-types'
import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error'
import {
applyRuntimeEnvironmentCapabilityVerdict,
captureRuntimeEnvironmentCapabilityEvidence,
getAcceptedRuntimeEnvironmentCapabilityOutcome,
isRuntimeEnvironmentCapabilityOutcomeCurrent,
runtimeEnvironmentCapabilityOutcome,
resetRuntimeEnvironmentCapabilityEvidence,
type RuntimeEnvironmentCapabilityOutcome
} from './runtime-environment-capability-evidence'
import { pauseRemoteRuntimeSharedControlRetry } from './runtime-environment-request-connections'
const sharedControlSupport = new Map<
string,
{ cacheKey: string; check: Promise<RuntimeEnvironmentCapabilityOutcome> }
>()
import {
getRuntimeEnvironmentStatusOwner,
resetRuntimeEnvironmentStatusOwners
} from './runtime-environment-request-connections'
export function resetSharedControlSupport(): void {
sharedControlSupport.clear()
resetRuntimeEnvironmentStatusOwners()
resetRuntimeEnvironmentCapabilityEvidence()
}
export function clearSharedControlSupport(environmentId: string): void {
sharedControlSupport.delete(environmentId)
}
export async function supportsSharedControl(
userDataPath: string,
environment: KnownRuntimeEnvironment,
@@ -48,85 +32,17 @@ export async function supportsSharedControl(
if (accepted) {
return accepted
}
const cacheKey = getSharedControlSupportCacheKey(environment, pairing)
const cached = sharedControlSupport.get(environment.id)
if (cached?.cacheKey === cacheKey) {
const outcome = await cached.check
if (isRuntimeEnvironmentCapabilityOutcomeCurrent(outcome)) {
return outcome
}
if (sharedControlSupport.get(environment.id)?.check === cached.check) {
sharedControlSupport.delete(environment.id)
}
return { kind: 'stale_incarnation' }
const response = await getRuntimeEnvironmentStatusOwner(userDataPath, environment.id).refresh({
timeoutMs
})
if (!response.ok) {
throw new RemoteRuntimeClientError(response.error.code, response.error.message)
}
let resolvedCacheKey = cacheKey
const evidence = captureRuntimeEnvironmentCapabilityEvidence(environment.id, pairing)
const check = (async () => {
const response = await sendRemoteRuntimeRequest<RuntimeStatus>(
return (
getAcceptedRuntimeEnvironmentCapabilityOutcome(
environment.id,
pairing,
'status.get',
undefined,
timeoutMs,
undefined,
undefined,
ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES
)
if (response.ok === true) {
const verdict = response.result.capabilities?.includes(
REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY
)
? 'capable'
: 'absent'
const acceptedEvidence = applyRuntimeEnvironmentCapabilityVerdict({
evidence,
verdict,
runtimeId: response._meta.runtimeId,
onAbsent: () => pauseRemoteRuntimeSharedControlRetry(environment.id)
})
if (!acceptedEvidence) {
return { kind: 'stale_incarnation' } as const
}
markEnvironmentUsed(userDataPath, environment.id, { runtimeId: response._meta.runtimeId })
resolvedCacheKey = getSharedControlSupportCacheKey(
environment,
pairing,
response._meta.runtimeId
)
return runtimeEnvironmentCapabilityOutcome(evidence, verdict, response._meta.runtimeId)
}
return runtimeEnvironmentCapabilityOutcome(
evidence,
'absent',
environment.runtimeId ?? 'unknown-runtime'
)
})()
// Why: support belongs to the saved pairing/runtime identity, not its mutable display name.
sharedControlSupport.set(environment.id, { cacheKey, check })
try {
const outcome = await check
const cachedAfterCheck = sharedControlSupport.get(environment.id)
if (cachedAfterCheck?.check === check && cachedAfterCheck.cacheKey !== resolvedCacheKey) {
sharedControlSupport.set(environment.id, { cacheKey: resolvedCacheKey, check })
}
return outcome
} catch (error) {
if (sharedControlSupport.get(environment.id)?.check === check) {
sharedControlSupport.delete(environment.id)
}
throw error
}
}
function getSharedControlSupportCacheKey(
environment: KnownRuntimeEnvironment,
pairing: ReturnType<typeof getPreferredPairingOffer>,
runtimeId = environment.runtimeId
): string {
return [
runtimeId ?? 'unknown-runtime',
pairing.endpoint,
pairing.deviceToken,
pairing.publicKeyB64
].join('\0')
response._meta.runtimeId
) ?? { kind: 'stale_incarnation' }
)
}
@@ -0,0 +1,65 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import { encodePairingOffer } from '../../shared/pairing'
import { addEnvironmentFromPairingCode } from '../../shared/runtime-environment-store'
import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version'
import {
createSharedControlTestServer,
closeSharedControlTestServers
} from '../../shared/remote-runtime-shared-control-test-server'
import { getRuntimeEnvironmentStatus } from './runtime-environment-transport-routing'
import {
getRuntimeEnvironmentStatusOwner,
resetRuntimeEnvironmentStatusOwners
} from './runtime-environment-request-connections'
vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } }))
const profiles: string[] = []
afterEach(async () => {
resetRuntimeEnvironmentStatusOwners()
await closeSharedControlTestServers()
profiles.splice(0).forEach((profile) => rmSync(profile, { recursive: true, force: true }))
})
it('publishes real same-socket verification after every authenticated reconnect', async () => {
let runtimeId = 'host-before'
const server = await createSharedControlTestServer({
resultForRequest: () => ({
runtimeId,
capabilities: [REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY]
})
})
const profile = mkdtempSync(join(tmpdir(), 'orca-status-socket-'))
profiles.push(profile)
const environment = addEnvironmentFromPairingCode(profile, {
name: 'host',
pairingCode: encodePairingOffer(server.pairing)
})
await getRuntimeEnvironmentStatus(profile, environment.id)
const owner = getRuntimeEnvironmentStatusOwner(profile, environment.id)
await vi.waitFor(
() => {
expect(owner.read()).toMatchObject({ transport: 'ready', verification: 'verified' })
expect(server.requests).toHaveLength(2)
},
{ timeout: 3_000 }
)
expect(server.connectionCount()).toBe(2) // Bootstrap plus persistent control.
runtimeId = 'host-after'
server.closeClients()
await vi.waitFor(
() => {
expect(owner.read().status?.runtimeId).toBe('host-after')
expect(owner.read().verification).toBe('verified')
},
{ timeout: 3_000 }
)
expect(server.connectionCount()).toBe(3)
expect(server.requests.map((request) => request.method)).toEqual([
'status.get',
'status.get',
'status.get'
])
})
@@ -0,0 +1,89 @@
import { BrowserWindow } from 'electron'
import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client'
import {
ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES,
REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY
} from '../../shared/protocol-version'
import {
getPreferredPairingOffer,
type KnownRuntimeEnvironment
} from '../../shared/runtime-environments'
import { markEnvironmentUsed } from '../../shared/runtime-environment-store'
import { RuntimeHostStatusOwner } from '../../shared/runtime-host-status-owner'
import {
RUNTIME_HOST_STATUS_CHANNEL,
type RuntimeHostStatusResponse
} from '../../shared/runtime-host-status'
import {
applyRuntimeEnvironmentCapabilityVerdict,
getAcceptedRuntimeEnvironmentCapabilityOutcome,
captureRuntimeEnvironmentCapabilityEvidence
} from './runtime-environment-capability-evidence'
import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect'
export function createRuntimeEnvironmentStatusOwner(
userDataPath: string,
environment: KnownRuntimeEnvironment,
transport: {
isReady: () => boolean
request: (signal: AbortSignal) => Promise<RuntimeHostStatusResponse>
establish: () => void
pause: () => void
}
): RuntimeHostStatusOwner {
const pairing = getPreferredPairingOffer(environment)
let evidence = captureRuntimeEnvironmentCapabilityEvidence(environment.id, pairing)
return new RuntimeHostStatusOwner({
environmentId: environment.id,
pairingRevision: environment.pairingRevision ?? environment.createdAt,
request: (signal) => {
evidence = captureRuntimeEnvironmentCapabilityEvidence(environment.id, pairing)
return transport.isReady() &&
getAcceptedRuntimeEnvironmentCapabilityOutcome(environment.id, pairing, null)?.kind ===
'supported'
? transport.request(signal)
: sendRemoteRuntimeRequest(
pairing,
'status.get',
undefined,
15_000,
undefined,
signal,
ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES
)
},
verified: (response, active) => {
const capable =
response.result.capabilities?.includes(REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY) ?? false
const accepted = applyRuntimeEnvironmentCapabilityVerdict({
evidence,
verdict: capable ? 'capable' : 'absent',
runtimeId: response._meta.runtimeId
})
if (accepted && active && !isRuntimeEnvironmentManuallyDisconnected(environment.id)) {
markEnvironmentUsed(userDataPath, environment.id, {
runtimeId: response._meta.runtimeId,
pairedDeviceId: response.result.pairedDeviceId
})
if (capable) {
transport.establish()
} else {
transport.pause()
}
}
return capable && active
},
publish: (snapshot) => {
for (const window of BrowserWindow.getAllWindows()) {
if (window.isDestroyed()) {
continue
}
try {
window.webContents.send(RUNTIME_HOST_STATUS_CHANNEL, snapshot)
} catch {
/* A renderer can close during publication. */
}
}
}
})
}
@@ -0,0 +1,89 @@
import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { addEnvironmentFromPairingCode } from '../../shared/runtime-environment-store'
import { pairingCode } from './runtime-environments-ipc-test-harness'
import {
getRuntimeEnvironmentStatus,
resetSharedControlSupport
} from './runtime-environment-transport-routing'
const { request, publish } = vi.hoisted(() => ({ request: vi.fn(), publish: vi.fn() }))
vi.mock('../../shared/remote-runtime-client', () => ({
sendRemoteRuntimeRequest: request,
subscribeRemoteRuntimeRequest: vi.fn()
}))
vi.mock('electron', () => ({
BrowserWindow: {
getAllWindows: () => [
{
isDestroyed: () => false,
webContents: { send: publish }
}
]
}
}))
let profile: string
beforeEach(() => {
vi.useFakeTimers()
request.mockReset()
publish.mockReset()
profile = mkdtempSync(join(tmpdir(), 'orca-status-recovery-'))
})
afterEach(() => {
resetSharedControlSupport()
vi.useRealTimers()
rmSync(profile, { recursive: true, force: true })
})
it('recovers a saved host after its first status check fails, without another UI request', async () => {
const environment = addEnvironmentFromPairingCode(profile, {
name: 'offline-at-startup',
pairingCode: pairingCode()
})
request
.mockRejectedValueOnce(
Object.assign(new Error('host offline'), { code: 'runtime_unavailable' })
)
.mockResolvedValue({
id: 'status',
ok: true,
result: { runtimeId: 'host-1', graphStatus: 'ready', capabilities: [] },
_meta: { runtimeId: 'host-1' }
})
expect((await getRuntimeEnvironmentStatus(profile, environment.id)).ok).toBe(false)
await vi.advanceTimersByTimeAsync(3_000)
expect(request).toHaveBeenCalledTimes(2)
expect(publish).toHaveBeenCalledWith(
'runtimeEnvironments:statusChanged',
expect.objectContaining({
environmentId: environment.id,
verification: 'verified',
status: expect.objectContaining({ runtimeId: 'host-1' })
})
)
await vi.advanceTimersByTimeAsync(300_000)
expect(request).toHaveBeenCalledTimes(2)
})
it('a passive capability check does not strand later active bootstrap recovery', async () => {
const environment = addEnvironmentFromPairingCode(profile, {
name: 'passive-first',
pairingCode: pairingCode()
})
request
.mockResolvedValueOnce({
id: 'status',
ok: true,
result: { runtimeId: 'host-1', capabilities: [REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY] },
_meta: { runtimeId: 'host-1' }
})
.mockRejectedValue(new Error('host offline'))
await getRuntimeEnvironmentStatus(profile, environment.id, undefined, { observeOnly: true })
await getRuntimeEnvironmentStatus(profile, environment.id)
await vi.advanceTimersByTimeAsync(3_000)
expect(request).toHaveBeenCalledTimes(3)
})
@@ -57,7 +57,6 @@ describe('runtime environment support routing', () => {
).resolves.toMatchObject({ ok: true })
expect(supportsMock).toHaveBeenCalledTimes(2)
expect(clearSupportMock).toHaveBeenCalledOnce()
expect(supported).toHaveBeenCalledOnce()
expect(unsupported).not.toHaveBeenCalled()
})
@@ -18,10 +18,7 @@ import {
type RuntimeEnvironmentCapabilityOutcome
} from './runtime-environment-capability-evidence'
import { runtimeEnvironmentRevisionFailure } from './runtime-environment-revision-guard'
import {
clearSharedControlSupport,
supportsSharedControl
} from './runtime-environment-shared-control-support'
import { supportsSharedControl } from './runtime-environment-shared-control-support'
import {
sendRemoteRuntimeRequestAbortable,
sendRemoteRuntimeSharedControlRequestAbortable
@@ -205,7 +202,6 @@ export async function routeRuntimeEnvironmentCallBySupport(args: {
}
return response
}
clearSharedControlSupport(environment.id)
environment = resolveEnvironment(args.userDataPath, environment.id)
}
return runtimeEnvironmentChangedFailure(environment, args.method)
@@ -1,20 +1,23 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { generateKeyPair, publicKeyToBase64 } from '../../shared/e2ee-crypto'
import { encodePairingOffer, type PairingOffer } from '../../shared/pairing'
import { addEnvironmentFromPairingCode } from '../../shared/runtime-environment-store'
import {
callRuntimeEnvironment,
getRuntimeEnvironmentStatus,
subscribeRuntimeEnvironment
subscribeRuntimeEnvironment,
resetSharedControlSupport
} from './runtime-environment-transport-routing'
// Why: prove the wiring, not just the helper — an unreachable endpoint exercises
// the real WebSocket failure → reject → Tailscale-hint join points the settings
// probe (returned ok:false) and in-use calls (thrown) actually use.
vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } }))
let userDataPath: string
function seedEnvironment(name: string, endpoint: string): string {
@@ -39,6 +42,7 @@ beforeEach(() => {
})
afterEach(() => {
resetSharedControlSupport()
rmSync(userDataPath, { recursive: true, force: true })
})
@@ -1,8 +1,5 @@
import { getPreferredPairingOffer } from '../../shared/runtime-environments'
import {
ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES,
REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY
} from '../../shared/protocol-version'
import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/protocol-version'
import { resolveEnvironment, markEnvironmentUsed } from '../../shared/runtime-environment-store'
import { isOrchestrationMutation } from '../../shared/orchestration-rpc-contract'
import type {
@@ -11,33 +8,22 @@ import type {
} from '../../shared/runtime-rpc-envelope'
import type { RuntimeStatus } from '../../shared/runtime-types'
import {
sendRemoteRuntimeRequest,
subscribeRemoteRuntimeRequest,
type RemoteRuntimeSubscription
} from '../../shared/remote-runtime-client'
import { withRemoteRuntimeTailscaleHint } from '../../shared/remote-runtime-tailscale-hint'
import { enqueueRuntimeCall } from './runtime-environment-call-queue'
import {
ensureRemoteRuntimeSharedControlConnection,
pauseRemoteRuntimeSharedControlRetry,
reconnectRemoteRuntimeSharedControlConnection
} from './runtime-environment-request-connections'
import { getRuntimeEnvironmentStatusOwner } from './runtime-environment-request-connections'
import {
sendRemoteRuntimeConnectionRequestAbortable,
sendRemoteRuntimeRequestAbortable
} from './runtime-environment-abortable-requests'
import { attachRemoteControlDiagnostics } from './runtime-environment-status-diagnostics'
import {
applyRuntimeEnvironmentCapabilityVerdict,
captureRuntimeEnvironmentCapabilityEvidence
} from './runtime-environment-capability-evidence'
import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect'
import { runtimeEnvironmentRevisionFailure } from './runtime-environment-revision-guard'
import { withTailscaleHintForResponse } from './runtime-environment-tailscale-response'
import {
clearSharedControlSupport,
resetSharedControlSupport
} from './runtime-environment-shared-control-support'
import { resetSharedControlSupport } from './runtime-environment-shared-control-support'
import {
executeSupportRoutedCall,
shouldRouteCallBySupport,
@@ -47,72 +33,31 @@ import {
const DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS = 15_000
export { clearSharedControlSupport, resetSharedControlSupport }
export { resetSharedControlSupport }
export async function getRuntimeEnvironmentStatus(
userDataPath: string,
selector: string,
timeoutMs?: number,
options?: { observeOnly?: true }
options?: { observeOnly?: true; signal?: AbortSignal; reconnect?: true }
): Promise<RuntimeRpcResponse<RuntimeStatus>> {
const environment = resolveEnvironment(userDataPath, selector)
const pairing = getPreferredPairingOffer(environment)
const evidence = captureRuntimeEnvironmentCapabilityEvidence(environment.id, pairing)
let response: RuntimeRpcResponse<RuntimeStatus>
try {
response = await sendRemoteRuntimeRequest<RuntimeStatus>(
pairing,
'status.get',
undefined,
timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS,
undefined,
undefined,
ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES
)
} catch (error) {
// Why: the status UI needs shared-control diagnostics most when the
// fresh status probe failed and the host is reconnecting/offline.
return attachRemoteControlDiagnostics(
withTailscaleHintForResponse(
{
id: 'status.get',
ok: false,
error: {
code: 'runtime_unavailable',
message: error instanceof Error ? error.message : String(error)
},
_meta: { runtimeId: environment.runtimeId }
},
pairing.endpoint
),
environment.id
)
}
if (response.ok === true) {
const verdict = response.result.capabilities?.includes(REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY)
? 'capable'
: 'absent'
const accepted = applyRuntimeEnvironmentCapabilityVerdict({
evidence,
verdict,
runtimeId: response._meta.runtimeId,
onCapable: () => {
if (!options?.observeOnly && !isRuntimeEnvironmentManuallyDisconnected(environment.id)) {
ensureRemoteRuntimeSharedControlConnection(environment.id, pairing)
reconnectRemoteRuntimeSharedControlConnection(environment.id)
}
},
onAbsent: () => pauseRemoteRuntimeSharedControlRetry(environment.id)
})
if (accepted && !options?.observeOnly) {
markEnvironmentUsed(userDataPath, environment.id, {
runtimeId: response._meta.runtimeId,
pairedDeviceId: response.result.pairedDeviceId
})
if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) {
return {
id: 'status.get',
ok: false,
error: {
code: 'runtime_manually_disconnected',
message: 'Runtime environment is manually disconnected.'
}
}
}
const response = await getRuntimeEnvironmentStatusOwner(userDataPath, environment.id).refresh({
timeoutMs,
...options
})
return attachRemoteControlDiagnostics(
withTailscaleHintForResponse(response, pairing.endpoint),
withTailscaleHintForResponse(response, getPreferredPairingOffer(environment).endpoint),
environment.id
)
}
@@ -127,6 +72,15 @@ export async function callRuntimeEnvironment(
envelope?: RuntimeOrchestrationEnvelope,
options?: { signal?: AbortSignal }
): Promise<RuntimeRpcResponse<unknown>> {
if (method === 'status.get') {
const environment = resolveEnvironment(userDataPath, selector)
const failure = runtimeEnvironmentRevisionFailure(
environment,
expectedEnvironmentPairingRevision,
method
)
return failure ?? getRuntimeEnvironmentStatus(userDataPath, selector, timeoutMs, options)
}
const environment = resolveEnvironment(userDataPath, selector)
// Why: connection failures reject (they don't resolve as ok:false), so the
// Tailscale hint is applied to the thrown error here — wrapping the resolved
@@ -1,3 +1,4 @@
import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -44,6 +45,7 @@ const {
}))
vi.mock('electron', () => ({
BrowserWindow: { getAllWindows: () => [] },
app: { getPath: getPathMock },
ipcMain: {
handle: handleMock,
@@ -58,18 +60,23 @@ vi.mock('../../shared/remote-runtime-client', () => ({
subscribeRemoteRuntimeRequest: subscribeRemoteRuntimeRequestMock
}))
vi.mock('./runtime-environment-request-connections', () => ({
sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock,
sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock,
subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock,
getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock,
reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock,
retryRemoteRuntimeSharedControlConnectionsNow: retryRemoteRuntimeSharedControlConnectionsNowMock,
retryRemoteRuntimeSharedControlConnectionNow: vi.fn(),
ensureRemoteRuntimeSharedControlConnection: vi.fn(),
pauseRemoteRuntimeSharedControlRetry: vi.fn(),
closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock
}))
vi.mock('./runtime-environment-request-connections', async () => {
const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness')
return withRuntimeStatusOwners({
sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock,
sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock,
subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock,
getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock,
reconnectRemoteRuntimeSharedControlConnection:
reconnectRemoteRuntimeSharedControlConnectionMock,
retryRemoteRuntimeSharedControlConnectionsNow:
retryRemoteRuntimeSharedControlConnectionsNowMock,
retryRemoteRuntimeSharedControlConnectionNow: vi.fn(),
ensureRemoteRuntimeSharedControlConnection: vi.fn(),
pauseRemoteRuntimeSharedControlRetry: vi.fn(),
closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock
})
})
import { registerRuntimeEnvironmentHandlers } from './runtime-environments'
import { channelHandlerLookup, pairingCode } from './runtime-environments-ipc-test-harness'
@@ -112,6 +119,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
afterEach(() => {
resetRuntimeEnvironmentStatusOwners()
rmSync(userDataPath, { recursive: true, force: true })
})
@@ -339,7 +347,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
undefined,
15_000,
undefined,
undefined,
expect.any(AbortSignal),
ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES
)
expect(sendRemoteRuntimeSharedControlRequestMock).toHaveBeenCalledWith(
@@ -451,7 +459,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
}
)
it('keeps uncoded call failures on the rejected IPC fallback path', async () => {
it('returns uncoded status failures through the owner response', async () => {
registerRuntimeEnvironmentHandlers(store as never)
sendRemoteRuntimeRequestMock.mockRejectedValue(new Error('shared down'))
@@ -464,9 +472,10 @@ describe('registerRuntimeEnvironmentHandlers', () => {
'runtimeEnvironments:call'
)
await expect(call(null, { selector: 'desk', method: 'status.get' })).rejects.toThrow(
'shared down'
)
await expect(call(null, { selector: 'desk', method: 'status.get' })).resolves.toMatchObject({
ok: false,
error: { code: 'runtime_unavailable', message: 'shared down' }
})
})
it('does not fall back after a shared-control request fails on a supported runtime', async () => {
@@ -1,3 +1,4 @@
import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -37,6 +38,7 @@ const {
}))
vi.mock('electron', () => ({
BrowserWindow: { getAllWindows: () => [] },
app: { getPath: getPathMock },
ipcMain: {
handle: handleMock,
@@ -51,18 +53,23 @@ vi.mock('../../shared/remote-runtime-client', () => ({
subscribeRemoteRuntimeRequest: subscribeRemoteRuntimeRequestMock
}))
vi.mock('./runtime-environment-request-connections', () => ({
sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock,
sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock,
subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock,
getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock,
reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock,
retryRemoteRuntimeSharedControlConnectionsNow: retryRemoteRuntimeSharedControlConnectionsNowMock,
retryRemoteRuntimeSharedControlConnectionNow: vi.fn(),
ensureRemoteRuntimeSharedControlConnection: vi.fn(),
pauseRemoteRuntimeSharedControlRetry: vi.fn(),
closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock
}))
vi.mock('./runtime-environment-request-connections', async () => {
const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness')
return withRuntimeStatusOwners({
sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock,
sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock,
subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock,
getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock,
reconnectRemoteRuntimeSharedControlConnection:
reconnectRemoteRuntimeSharedControlConnectionMock,
retryRemoteRuntimeSharedControlConnectionsNow:
retryRemoteRuntimeSharedControlConnectionsNowMock,
retryRemoteRuntimeSharedControlConnectionNow: vi.fn(),
ensureRemoteRuntimeSharedControlConnection: vi.fn(),
pauseRemoteRuntimeSharedControlRetry: vi.fn(),
closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock
})
})
import { registerRuntimeEnvironmentHandlers } from './runtime-environments'
import { channelHandlerLookup, pairingCode } from './runtime-environments-ipc-test-harness'
@@ -105,6 +112,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
afterEach(() => {
resetRuntimeEnvironmentStatusOwners()
rmSync(userDataPath, { recursive: true, force: true })
})
@@ -182,9 +190,10 @@ describe('registerRuntimeEnvironmentHandlers', () => {
{ selector: string; method: string; params?: unknown; timeoutMs?: number },
{ ok: true; result: unknown }
>('runtimeEnvironments:call')
await expect(call(null, { selector: 'desk', method: 'repo.list' })).rejects.toThrow(
'probe failed'
)
await expect(call(null, { selector: 'desk', method: 'repo.list' })).resolves.toMatchObject({
ok: false,
error: { code: 'runtime_unavailable', message: 'probe failed' }
})
await expect(call(null, { selector: 'desk', method: 'repo.list' })).resolves.toMatchObject({
ok: true,
result: { repos: [] }
@@ -1,6 +1,62 @@
import { expect } from 'vitest'
import type { Mock } from 'vitest'
import { getPreferredPairingOffer } from '../../shared/runtime-environments'
import { encodePairingOffer } from '../../shared/pairing'
import { resolveEnvironment } from '../../shared/runtime-environment-store'
import { createRuntimeEnvironmentStatusOwner } from './runtime-environment-status-owner'
import type { RuntimeHostStatusOwner } from '../../shared/runtime-host-status-owner'
import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect'
/** Keep IPC tests on the production owner while replacing only its transport. */
export function withRuntimeStatusOwners<T extends Record<string, Mock>>(transport: T) {
const owners = new Map<string, RuntimeHostStatusOwner>()
return {
...transport,
getRuntimeEnvironmentStatusOwner: (profile: string, selector: string) => {
const environment = resolveEnvironment(profile, selector)
let owner = owners.get(environment.id)
if (!owner || owner.read().retired) {
owner = createRuntimeEnvironmentStatusOwner(profile, environment, {
isReady: () =>
transport.getRemoteRuntimeSharedControlDiagnostics?.(environment.id)?.state === 'ready',
request: (signal) =>
transport.sendRemoteRuntimeSharedControlRequest(
environment.id,
undefined,
'status.get',
undefined,
15_000,
undefined,
signal
),
establish: () => {
transport.ensureRemoteRuntimeSharedControlConnection?.(
environment.id,
getPreferredPairingOffer(environment)
)
transport.reconnectRemoteRuntimeSharedControlConnection?.(environment.id)
},
pause: () => transport.pauseRemoteRuntimeSharedControlRetry?.(environment.id)
})
owners.set(environment.id, owner)
if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) {
owner.dispose()
}
}
return owner
},
getRuntimeEnvironmentStatusSnapshots: () => [...owners.values()].map((owner) => owner.read()),
resetRuntimeEnvironmentStatusOwners: () => {
owners.forEach((owner) => owner.dispose())
owners.clear()
},
closeRemoteRuntimeRequestConnection: (...args: unknown[]) => {
owners.get(args[0] as string)?.dispose()
owners.delete(args[0] as string)
transport.closeRemoteRuntimeRequestConnection(...args)
}
}
}
export function pairingCode(endpoint = 'ws://127.0.0.1:6768'): string {
return encodePairingOffer({
@@ -1,3 +1,5 @@
import type { RuntimeHostStatusSnapshot } from '../../shared/runtime-host-status'
import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -44,6 +46,7 @@ const {
}))
vi.mock('electron', () => ({
BrowserWindow: { getAllWindows: () => [] },
app: { getPath: getPathMock },
ipcMain: {
handle: handleMock,
@@ -58,18 +61,23 @@ vi.mock('../../shared/remote-runtime-client', () => ({
subscribeRemoteRuntimeRequest: subscribeRemoteRuntimeRequestMock
}))
vi.mock('./runtime-environment-request-connections', () => ({
sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock,
sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock,
subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock,
getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock,
reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock,
retryRemoteRuntimeSharedControlConnectionsNow: retryRemoteRuntimeSharedControlConnectionsNowMock,
retryRemoteRuntimeSharedControlConnectionNow: retryRemoteRuntimeSharedControlConnectionNowMock,
ensureRemoteRuntimeSharedControlConnection: vi.fn(),
pauseRemoteRuntimeSharedControlRetry: vi.fn(),
closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock
}))
vi.mock('./runtime-environment-request-connections', async () => {
const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness')
return withRuntimeStatusOwners({
sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock,
sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock,
subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock,
getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock,
reconnectRemoteRuntimeSharedControlConnection:
reconnectRemoteRuntimeSharedControlConnectionMock,
retryRemoteRuntimeSharedControlConnectionsNow:
retryRemoteRuntimeSharedControlConnectionsNowMock,
retryRemoteRuntimeSharedControlConnectionNow: retryRemoteRuntimeSharedControlConnectionNowMock,
ensureRemoteRuntimeSharedControlConnection: vi.fn(),
pauseRemoteRuntimeSharedControlRetry: vi.fn(),
closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock
})
})
import { registerRuntimeEnvironmentHandlers } from './runtime-environments'
import { channelHandlerLookup, pairingCode } from './runtime-environments-ipc-test-harness'
@@ -125,6 +133,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
afterEach(() => {
resetRuntimeEnvironmentStatusOwners()
rmSync(userDataPath, { recursive: true, force: true })
})
@@ -132,6 +141,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
registerRuntimeEnvironmentHandlers(store as never)
expect(handleMock.mock.calls.map((call) => call[0])).toEqual([
'runtimeEnvironments:getStatusSnapshots',
'runtimeEnvironments:list',
'runtimeEnvironments:addFromPairingCode',
'runtimeEnvironments:verifyAndAddFromPairingCode',
@@ -166,6 +176,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
'runtimeEnvironments:retryControlConnection',
'runtimeEnvironments:prepareBrowserClientHostPlacement',
'runtimeEnvironments:getStatus',
'runtimeEnvironments:getStatusSnapshots',
'runtimeEnvironments:call',
'runtimeEnvironments:subscribe',
'runtimeEnvironments:unsubscribe',
@@ -467,6 +478,13 @@ describe('registerRuntimeEnvironmentHandlers', () => {
ok: false,
error: { code: 'runtime_manually_disconnected' }
})
const getSnapshots = handler<undefined, RuntimeHostStatusSnapshot[]>(
'runtimeEnvironments:getStatusSnapshots'
)
// A new renderer only has the snapshot read, not the earlier disconnect event.
expect(await getSnapshots(null, undefined)).toMatchObject([
{ environmentId: added.environment.id, retired: true, transport: 'disconnected' }
])
const call = handler<
{ selector: string; method: string },
{ ok: boolean; error?: { code: string } }
@@ -492,6 +510,10 @@ describe('registerRuntimeEnvironmentHandlers', () => {
result: { runtimeId: 'runtime-remote' }
})
expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledOnce()
expect(await getSnapshots(null, undefined)).toMatchObject([
{ environmentId: added.environment.id, verification: 'verified' }
])
expect((await getSnapshots(null, undefined))[0].retired).not.toBe(true)
})
it('marks environments owned by ephemeral VM runtimes in the public list', async () => {
@@ -1,3 +1,4 @@
import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -44,6 +45,7 @@ const {
}))
vi.mock('electron', () => ({
BrowserWindow: { getAllWindows: () => [] },
app: { getPath: getPathMock },
ipcMain: {
handle: handleMock,
@@ -58,18 +60,23 @@ vi.mock('../../shared/remote-runtime-client', () => ({
subscribeRemoteRuntimeRequest: subscribeRemoteRuntimeRequestMock
}))
vi.mock('./runtime-environment-request-connections', () => ({
sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock,
sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock,
subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock,
getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock,
reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock,
retryRemoteRuntimeSharedControlConnectionsNow: retryRemoteRuntimeSharedControlConnectionsNowMock,
retryRemoteRuntimeSharedControlConnectionNow: vi.fn(),
ensureRemoteRuntimeSharedControlConnection: ensureRemoteRuntimeSharedControlConnectionMock,
pauseRemoteRuntimeSharedControlRetry: pauseRemoteRuntimeSharedControlRetryMock,
closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock
}))
vi.mock('./runtime-environment-request-connections', async () => {
const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness')
return withRuntimeStatusOwners({
sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock,
sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock,
subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock,
getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock,
reconnectRemoteRuntimeSharedControlConnection:
reconnectRemoteRuntimeSharedControlConnectionMock,
retryRemoteRuntimeSharedControlConnectionsNow:
retryRemoteRuntimeSharedControlConnectionsNowMock,
retryRemoteRuntimeSharedControlConnectionNow: vi.fn(),
ensureRemoteRuntimeSharedControlConnection: ensureRemoteRuntimeSharedControlConnectionMock,
pauseRemoteRuntimeSharedControlRetry: pauseRemoteRuntimeSharedControlRetryMock,
closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock
})
})
import { registerRuntimeEnvironmentHandlers } from './runtime-environments'
import { channelHandlerLookup, pairingCode } from './runtime-environments-ipc-test-harness'
@@ -114,6 +121,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
afterEach(() => {
resetRuntimeEnvironmentStatusOwners()
rmSync(userDataPath, { recursive: true, force: true })
})
@@ -148,9 +156,9 @@ describe('registerRuntimeEnvironmentHandlers', () => {
expect.objectContaining({ endpoint: 'ws://127.0.0.1:6768', deviceToken: 'device-token' }),
'status.get',
undefined,
50,
undefined,
15_000,
undefined,
expect.any(AbortSignal),
ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES
)
expect(reconnectRemoteRuntimeSharedControlConnectionMock).toHaveBeenCalledWith(
@@ -319,36 +327,41 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
})
it('returns shared-control diagnostics when saved remote runtime status throws', async () => {
registerRuntimeEnvironmentHandlers(store as never)
getRemoteRuntimeSharedControlDiagnosticsMock.mockReturnValue({
state: 'reconnecting',
pendingRequestCount: 0,
subscriptionCount: 1,
reconnectAttempt: 2,
lastConnectedAt: 123,
lastClose: { code: 1006, reason: '' },
lastError: 'closed'
})
sendRemoteRuntimeRequestMock.mockRejectedValue(new Error('socket closed'))
it.each(['runtimeEnvironments:getStatus', 'runtimeEnvironments:connect'])(
'preserves failure diagnostics and guidance on %s',
async (channel) => {
registerRuntimeEnvironmentHandlers(store as never)
getRemoteRuntimeSharedControlDiagnosticsMock.mockReturnValue({
state: 'reconnecting',
pendingRequestCount: 0,
subscriptionCount: 1,
reconnectAttempt: 2,
lastConnectedAt: 123,
lastClose: { code: 1006, reason: '' },
lastError: 'closed'
})
sendRemoteRuntimeRequestMock.mockRejectedValue(
new Error('Could not connect to the remote Orca runtime.')
)
const add = handler<
{ name: string; pairingCode: string },
{ environment: { id: string; name: string } }
>('runtimeEnvironments:addFromPairingCode')
await add(null, { name: 'desk', pairingCode: pairingCode() })
const add = handler<
{ name: string; pairingCode: string },
{ environment: { id: string; name: string } }
>('runtimeEnvironments:addFromPairingCode')
await add(null, { name: 'desk', pairingCode: pairingCode() })
const getStatus = handler<
{ selector: string; timeoutMs?: number },
{ ok: false; error: { message: string; data?: { remoteControl?: { state: string } } } }
>('runtimeEnvironments:getStatus')
const getStatus = handler<
{ selector: string; timeoutMs?: number },
{ ok: false; error: { message: string; data?: { remoteControl?: { state: string } } } }
>(channel)
await expect(getStatus(null, { selector: 'desk' })).resolves.toMatchObject({
ok: false,
error: {
message: 'socket closed',
data: { remoteControl: { state: 'reconnecting' } }
}
})
})
await expect(getStatus(null, { selector: 'desk' })).resolves.toMatchObject({
ok: false,
error: {
message: expect.stringContaining('connect both devices to Tailscale'),
data: { remoteControl: { state: 'reconnecting' } }
}
})
}
)
})
@@ -1,3 +1,4 @@
import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -38,6 +39,7 @@ const {
}))
vi.mock('electron', () => ({
BrowserWindow: { getAllWindows: () => [] },
app: { getPath: getPathMock },
ipcMain: {
handle: handleMock,
@@ -52,18 +54,23 @@ vi.mock('../../shared/remote-runtime-client', () => ({
subscribeRemoteRuntimeRequest: subscribeRemoteRuntimeRequestMock
}))
vi.mock('./runtime-environment-request-connections', () => ({
sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock,
sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock,
subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock,
getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock,
reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock,
retryRemoteRuntimeSharedControlConnectionsNow: retryRemoteRuntimeSharedControlConnectionsNowMock,
retryRemoteRuntimeSharedControlConnectionNow: vi.fn(),
ensureRemoteRuntimeSharedControlConnection: vi.fn(),
pauseRemoteRuntimeSharedControlRetry: vi.fn(),
closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock
}))
vi.mock('./runtime-environment-request-connections', async () => {
const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness')
return withRuntimeStatusOwners({
sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock,
sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock,
subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock,
getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock,
reconnectRemoteRuntimeSharedControlConnection:
reconnectRemoteRuntimeSharedControlConnectionMock,
retryRemoteRuntimeSharedControlConnectionsNow:
retryRemoteRuntimeSharedControlConnectionsNowMock,
retryRemoteRuntimeSharedControlConnectionNow: vi.fn(),
ensureRemoteRuntimeSharedControlConnection: vi.fn(),
pauseRemoteRuntimeSharedControlRetry: vi.fn(),
closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock
})
})
import {
invalidateRuntimeEnvironmentTransport,
@@ -109,6 +116,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
afterEach(() => {
resetRuntimeEnvironmentStatusOwners()
rmSync(userDataPath, { recursive: true, force: true })
})
@@ -1,3 +1,4 @@
import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -40,6 +41,7 @@ const {
}))
vi.mock('electron', () => ({
BrowserWindow: { getAllWindows: () => [] },
app: { getPath: getPathMock },
ipcMain: {
handle: handleMock,
@@ -54,18 +56,23 @@ vi.mock('../../shared/remote-runtime-client', () => ({
subscribeRemoteRuntimeRequest: subscribeRemoteRuntimeRequestMock
}))
vi.mock('./runtime-environment-request-connections', () => ({
sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock,
sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock,
subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock,
getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock,
reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock,
retryRemoteRuntimeSharedControlConnectionsNow: retryRemoteRuntimeSharedControlConnectionsNowMock,
retryRemoteRuntimeSharedControlConnectionNow: vi.fn(),
ensureRemoteRuntimeSharedControlConnection: vi.fn(),
pauseRemoteRuntimeSharedControlRetry: vi.fn(),
closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock
}))
vi.mock('./runtime-environment-request-connections', async () => {
const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness')
return withRuntimeStatusOwners({
sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock,
sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock,
subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock,
getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock,
reconnectRemoteRuntimeSharedControlConnection:
reconnectRemoteRuntimeSharedControlConnectionMock,
retryRemoteRuntimeSharedControlConnectionsNow:
retryRemoteRuntimeSharedControlConnectionsNowMock,
retryRemoteRuntimeSharedControlConnectionNow: vi.fn(),
ensureRemoteRuntimeSharedControlConnection: vi.fn(),
pauseRemoteRuntimeSharedControlRetry: vi.fn(),
closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock
})
})
import { registerRuntimeEnvironmentHandlers } from './runtime-environments'
import { channelHandlerLookup, pairingCode } from './runtime-environments-ipc-test-harness'
@@ -108,6 +115,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
afterEach(() => {
resetRuntimeEnvironmentStatusOwners()
rmSync(userDataPath, { recursive: true, force: true })
})
@@ -1,3 +1,4 @@
import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -38,6 +39,7 @@ const {
}))
vi.mock('electron', () => ({
BrowserWindow: { getAllWindows: () => [] },
app: { getPath: getPathMock },
ipcMain: {
handle: handleMock,
@@ -52,18 +54,23 @@ vi.mock('../../shared/remote-runtime-client', () => ({
subscribeRemoteRuntimeRequest: subscribeRemoteRuntimeRequestMock
}))
vi.mock('./runtime-environment-request-connections', () => ({
sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock,
sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock,
subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock,
getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock,
reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock,
retryRemoteRuntimeSharedControlConnectionsNow: retryRemoteRuntimeSharedControlConnectionsNowMock,
retryRemoteRuntimeSharedControlConnectionNow: vi.fn(),
ensureRemoteRuntimeSharedControlConnection: vi.fn(),
pauseRemoteRuntimeSharedControlRetry: vi.fn(),
closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock
}))
vi.mock('./runtime-environment-request-connections', async () => {
const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness')
return withRuntimeStatusOwners({
sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock,
sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock,
subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock,
getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock,
reconnectRemoteRuntimeSharedControlConnection:
reconnectRemoteRuntimeSharedControlConnectionMock,
retryRemoteRuntimeSharedControlConnectionsNow:
retryRemoteRuntimeSharedControlConnectionsNowMock,
retryRemoteRuntimeSharedControlConnectionNow: vi.fn(),
ensureRemoteRuntimeSharedControlConnection: vi.fn(),
pauseRemoteRuntimeSharedControlRetry: vi.fn(),
closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock
})
})
vi.mock('../browser/paired-runtime-browser-client-host-runtime', () => ({
retirePairedRuntimeBrowserClientHostEnvironment:
retirePairedRuntimeBrowserClientHostEnvironmentMock
@@ -115,6 +122,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
afterEach(() => {
resetRuntimeEnvironmentStatusOwners()
rmSync(userDataPath, { recursive: true, force: true })
})
+10 -4
View File
@@ -1,6 +1,6 @@
import { app, ipcMain } from 'electron'
import { randomUUID } from 'node:crypto'
import { resolveEnvironment } from '../../shared/runtime-environment-store'
import { listEnvironments, resolveEnvironment } from '../../shared/runtime-environment-store'
import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client'
import type { Store } from '../persistence'
import {
@@ -8,14 +8,16 @@ import {
registerRuntimeEnvironmentConnectivityHandlers,
registerRuntimeEnvironmentPassiveHandlers
} from './runtime-environment-connectivity-handlers'
import { closeRemoteRuntimeRequestConnection } from './runtime-environment-request-connections'
import {
closeRemoteRuntimeRequestConnection,
getRuntimeEnvironmentStatusOwner
} from './runtime-environment-request-connections'
import { registerRuntimeEnvironmentRecoveryHandler } from './runtime-environment-recovery-handler'
import {
advanceRuntimeEnvironmentTransportGeneration,
getRuntimeEnvironmentTransportGeneration
} from './runtime-environment-transport-generation'
import {
clearSharedControlSupport,
resetSharedControlSupport,
subscribeRuntimeEnvironment
} from './runtime-environment-transport-routing'
@@ -64,7 +66,6 @@ export function invalidateRuntimeEnvironmentTransport(environmentId: string): Pr
advanceRuntimeEnvironmentCapabilityIncarnation(environmentId)
advanceRuntimeEnvironmentTransportGeneration(environmentId)
closeRemoteRuntimeRequestConnection(environmentId)
clearSharedControlSupport(environmentId)
closeSubscriptionsForEnvironment(environmentId)
return retirePairedRuntimeBrowserClientHostEnvironment(
environmentId,
@@ -97,6 +98,11 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
})
registerRuntimeEnvironmentRecoveryHandler()
registerRuntimeEnvironmentPassiveHandlers(getUserDataPath)
for (const environment of listEnvironments(getUserDataPath())) {
if (!isRuntimeEnvironmentManuallyDisconnected(environment.id)) {
getRuntimeEnvironmentStatusOwner(getUserDataPath(), environment.id).activate()
}
}
ipcMain.handle(
'runtimeEnvironments:subscribe',
async (
@@ -40,11 +40,17 @@ export async function stopPtysForDestructiveWorktreeRemoval(
...(allowUnverifiedStop ? { allowUnverifiedStop: true } : {}),
...(connectionId ? { includeLocalRegistry: false } : {})
})
// Structured sessions are counted here too: closing a user's chat is now an ordinary outcome
// of this verb, and a removal that closed one but no PTY would otherwise log nothing at all.
const structuredStopped = teardownResult.structuredStopped ?? 0
const total =
teardownResult.runtimeStopped + teardownResult.providerStopped + teardownResult.registryStopped
teardownResult.runtimeStopped +
teardownResult.providerStopped +
teardownResult.registryStopped +
structuredStopped
if (total > 0) {
console.info(
`[worktree-teardown] ${worktreeId} killed runtime=${teardownResult.runtimeStopped} provider=${teardownResult.providerStopped} registry=${teardownResult.registryStopped}`
`[worktree-teardown] ${worktreeId} killed runtime=${teardownResult.runtimeStopped} provider=${teardownResult.providerStopped} registry=${teardownResult.registryStopped} structured=${structuredStopped}`
)
}
}
@@ -13,6 +13,7 @@ import type {
AgentSessionMutationResult,
AgentSessionWireRefusal
} from '../../../shared/agent-session-wire'
import { AGENT_SESSION_UNATTACHED_REFUSAL_CODE } from '../../../shared/structured-agent-session-read-refusal'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
@@ -21,8 +22,10 @@ import { runSettledAgentSessionMutation } from './structured-agent-session-opera
import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome'
import type { AgentSessionTurnContext } from './structured-agent-session-turns'
// The code is shared with the client so a read that refuses this way can be told apart from a
// transcript that failed to load; the two must never drift apart.
export const AGENT_SESSION_NOT_ATTACHED: AgentSessionWireRefusal = {
code: 'agent_session_ownership_unknown',
code: AGENT_SESSION_UNATTACHED_REFUSAL_CODE,
message: 'This host holds no attached session by that id.'
}
@@ -14,6 +14,7 @@ import type {
AgentSessionMutationEnvelope,
AgentSessionSubscribeEvent
} from '../../../shared/agent-session-wire'
import { AGENT_SESSION_UNATTACHED_REFUSAL_CODE } from '../../../shared/structured-agent-session-read-refusal'
import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
@@ -180,6 +181,25 @@ describe('a chat that closes', () => {
expect(host.hasSession(SESSION)).toBe(true)
})
// The pane outlives the close by a few frames — a workspace delete closes the chats inside it
// while their panes are still mounted — so whatever a read raises in that window is what the user
// sees. This is the code the client narrows on to keep that window off the pane; a host that
// starts raising a different one there puts the red error back.
it('answers a read from the pane that outlived it with the code the client treats as transitional', async () => {
await attach()
await host.hold(SESSION, SURFACE)
await host.close(SESSION)
expect(host.hasSession(SESSION)).toBe(false)
expect(() => host.history({ sessionId: SESSION, direction: 'tail' })).toThrow(
AGENT_SESSION_UNATTACHED_REFUSAL_CODE
)
expect(() =>
host.subscribe({ id: 'sub-1', sessionId: SESSION, emit: () => undefined })
).toThrow(AGENT_SESSION_UNATTACHED_REFUSAL_CODE)
})
it('does not lose the session to a release the client sent twice', async () => {
await attach()
await host.hold(SESSION, SURFACE)
@@ -24,6 +24,7 @@ type FakeCurlChild = {
}
export type AgentStatusExtensionHarness = {
killMock: ReturnType<typeof vi.fn>
fetchMock: ReturnType<typeof vi.fn>
spawnMock: ReturnType<typeof vi.fn>
spawnedChildren: FakeCurlChild[]
@@ -57,6 +58,7 @@ export const AGENT_STATUS_EXTENSION_SELF_PID = 4242
export function createAgentStatusExtensionHarness(args: {
kind: 'pi' | 'omp' | 'prime-agent'
killImpl?: (pid: number, signal: number) => void
env?: Record<string, string | undefined>
pid?: number
title?: string
@@ -115,7 +117,9 @@ export function createAgentStatusExtensionHarness(args: {
throw new Error(`unexpected require(${specifier})`)
})
const killMock = vi.fn(args.killImpl ?? (() => undefined))
const processMock = {
kill: killMock,
env: {
...BASE_ENV,
...(args.kind === 'prime-agent' ? { PRIME_AGENT_INTERNAL_DAEMON_WORKER: '1' } : {}),
@@ -172,6 +176,7 @@ export function createAgentStatusExtensionHarness(args: {
return {
fetchMock,
killMock,
spawnMock,
spawnedChildren,
fsMock,
+19 -1
View File
@@ -88,13 +88,31 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[]
'// etc.), so we forward the raw object verbatim under the same field',
'// names Claude uses (tool_name / tool_input) and let the server pick the',
'// preview. Keeps tool-name knowledge centralized on the receiver side.',
'// Why: a restarted agent inherits the previous owner PID through env, so a',
'// dead owner must be claimable or the pane goes silent for good. Only ESRCH',
'// proves the owner is gone -- every other probe result keeps suppression, so',
'// a live foreign owner still cannot double-report. Mirrors the tri-state in',
'// main/agent-hooks/managed-hook-owner-identity.ts, which this runtime cannot',
'// import (the extension loads inside pi/omp with no Orca deps).',
'function isStatusOwnerAlive(pid: string): boolean {',
' const parsed = Number(pid)',
' if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 0x7fffffff) return false',
" if (typeof process.kill !== 'function') return true",
' try {',
' process.kill(parsed, 0)',
' return true',
' } catch (err: unknown) {',
" return (err as { code?: string } | null)?.code !== 'ESRCH'",
' }',
'}',
'',
"// Why: child agents inherit the lead's pane env; only its process may",
'// register status hooks. PID identity keeps in-process reloads reporting.',
'export default function (pi): void {',
...primeDaemonWorkerGuard,
` const ownerPid = process.env.${ownerEnv}`,
' const selfPid = String(process.pid)',
' if (ownerPid && ownerPid !== selfPid) return',
' if (ownerPid && ownerPid !== selfPid && isStatusOwnerAlive(ownerPid)) return',
` process.env.${ownerEnv} = selfPid`,
...sessionStartHandler,
` pi.on('before_agent_start', (event${ctxParam}) => {`,
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest'
import {
createAgentStatusExtensionHarness as createHarness,
AGENT_STATUS_EXTENSION_SELF_PID as SELF_PID
} from './agent-status-extension-test-harness'
describe('Pi status owner recovery', () => {
it.each(['pi', 'omp', 'prime-agent'] as const)(
'claims the pane for a restarted %s agent whose inherited owner PID is dead',
async (kind) => {
// Why: STA-5245 -- a restart leaves a dead owner PID in the inherited env.
// Without a liveness probe the guard suppresses every later load, so the
// pane never reports status again.
const ownerKey =
kind === 'prime-agent' ? 'ORCA_PRIME_AGENT_STATUS_OWNED' : 'ORCA_PI_STATUS_OWNED'
const harness = createHarness({
kind,
pid: SELF_PID,
env: { [ownerKey]: String(SELF_PID - 1) },
killImpl: () => {
throw Object.assign(new Error('ESRCH'), { code: 'ESRCH' })
}
})
expect(harness.killMock).toHaveBeenCalledWith(SELF_PID - 1, 0)
expect(harness.handlers.agent_end).toBeTypeOf('function')
expect(harness.processEnv[ownerKey]).toBe(String(SELF_PID))
await harness.callHook('agent_end')
expect(harness.fetchMock).toHaveBeenCalledTimes(1)
}
)
it.each(['EPERM', 'EACCES', 'EINVAL', undefined])(
'keeps suppression for unverifiable probe error %s',
(code) => {
// Why: EPERM means the owner exists but belongs to another user, so
// claiming the pane there would reintroduce double-reporting.
const harness = createHarness({
kind: 'pi',
pid: SELF_PID,
env: { ORCA_PI_STATUS_OWNED: String(SELF_PID - 1) },
killImpl: () => {
throw Object.assign(new Error('probe failed'), { code })
}
})
expect(harness.handlers).toEqual({})
expect(harness.processEnv.ORCA_PI_STATUS_OWNED).toBe(String(SELF_PID - 1))
}
)
it('claims the pane when the inherited owner PID is not a usable pid', () => {
// Why: a truncated/garbage marker is not evidence of a live owner.
const harness = createHarness({
kind: 'pi',
pid: SELF_PID,
env: { ORCA_PI_STATUS_OWNED: 'not-a-pid' }
})
expect(harness.killMock).not.toHaveBeenCalled()
expect(harness.handlers.agent_end).toBeTypeOf('function')
expect(harness.processEnv.ORCA_PI_STATUS_OWNED).toBe(String(SELF_PID))
})
it('claims the pane when the inherited owner PID exceeds safe integer precision', () => {
const harness = createHarness({
kind: 'pi',
pid: SELF_PID,
env: { ORCA_PI_STATUS_OWNED: '99999999999999999999999' }
})
expect(harness.killMock).not.toHaveBeenCalled()
expect(harness.handlers.agent_end).toBeTypeOf('function')
expect(harness.processEnv.ORCA_PI_STATUS_OWNED).toBe(String(SELF_PID))
})
it('claims the pane when the inherited owner PID exceeds the process API range', () => {
const harness = createHarness({
kind: 'pi',
pid: SELF_PID,
env: { ORCA_PI_STATUS_OWNED: String(2 ** 31) }
})
expect(harness.killMock).not.toHaveBeenCalled()
expect(harness.handlers.agent_end).toBeTypeOf('function')
expect(harness.processEnv.ORCA_PI_STATUS_OWNED).toBe(String(SELF_PID))
})
})
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-09-11T06:10:52.713Z",
"platform": "darwin",
"command": ["agy"],
"cols": 120,
"rows": 40,
"note": "agy TUI 1.2.0; recording stopped ~0.3s after submit, while the spinner was live; no shutdown repaint in the file",
"exitCode": 0
}
@@ -0,0 +1,38 @@
[?2026$p[?2027$p[>4m[=0;1u[?1049h[?25l[?5W[?2004h[>4;2m[=1;1u[?u
▄▀▀▄
▀▀▀▀▀▀
▀▀▀▀▀▀▀▀
▄▀▀ ▀▀▄
▄▀▀ ▀▀▄
Welcome to the Antigravity CLI. You are currently not signed in.
⣾ Signing in...␍ No authentication methods available.
Press ctrl+c or ctrl+d twice to exit.[>4m[=0;1u[?1049l[>4;2m[=1;1u[?u[0 q␍
▄▀▀▄ Antigravity CLI 1.2.0
▀▀▀▀▀▀ Gemini API key
▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low)
▄▀▀ ▀▀▄ ~
▄▀▀ ▀▀▄
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
>
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
? for shortcutsGemini 3.7 Flash · low␍[?25h[?25lI[?25h[?25ln ab
 G[?25h[?25lout 8[?25h[?25l0 wo[?25h[?25lrds,[?25h[?25lexpla[?25h[?25lin w[?25h[?25lhat a[?25h[?25l pse[?25h[?25lud[?25h[?25loter[?25h[?25lminal[?25h[?25l is.[?25h[?25l[?25h[?25l
? for shortcuts[?25h[?25lM
> In about 80 words, explain what a pseudoterminal is.
⣷ Generating...
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
>
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
esc to cancelGemini 3.7 Flash · low␍[?25h[?25lng
[?25h[?25l␍⣯ Generating
[?25h[?25l␍⣟ Generating.
[?25h
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-09-11T06:13:00.364Z",
"platform": "darwin",
"command": ["agy"],
"cols": 120,
"rows": 40,
"note": "agy TUI 1.2.0; recording stopped after the turn ended and the composer returned, with the process still alive. This account's API key cannot complete a turn, so the turn ends in a backend error",
"exitCode": 0
}
@@ -0,0 +1,42 @@
[?2026$p[?2027$p[?5W[?2004h[>4;2m[=1;1u[?u[0 q␍
▄▀▀▄ Antigravity CLI 1.2.0
▀▀▀▀▀▀ Gemini API key
▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low)
▄▀▀ ▀▀▄ ~
▄▀▀ ▀▀▄
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
>
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
? for shortcutsGemini 3.7 Flash · low␍[?25h[?25lIn
 G[?25h[?25labo[?25h[?25lut 80[?25h[?25l wo[?25h[?25lrds[?25h[?25l, ex[?25h[?25lpla[?25h[?25lin wh[?25h[?25lat a[?25h[?25lpseudo[?25h[?25ltermi[?25h[?25lnal is[?25h[?25l.[?25h[?25l
? for shortcuts[?25h[?25lM
> In about 80 words, explain what a pseudoterminal is.
⣾ Generating...
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
>
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
esc to cancelGemini 3.7 Flash · low␍[?25h[?25l␍⣷ Generatin
[?25h[?25l␍⣯ Generating
[?25h[?25l␍⣟ Generating.
[?25h[?25l␍⡿ Generating...
[?25h[?25l␍⢿ Generatin
[?25h[?25l␍
⚠ Agent execution terminated due to error.
Error ID: 00000000-0000-4000-8000-000000000000-2
⢿ Generating...
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
>
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
esc to cancelGemini 3.7 Flash · low␍[?25h[?25l␍
? for shortcuts[?25h
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-09-11T04:34:32.974Z",
"platform": "darwin",
"command": ["agy"],
"cols": 120,
"rows": 40,
"note": "agy TUI 1.2.0; slash-command palette live, unanswered",
"exitCode": 0
}
@@ -0,0 +1,41 @@
[?2026$p[?2027$p[?5W[?2004h[>4;2m[=1;1u[?u[0 q␍
▄▀▀▄ Antigravity CLI 1.2.0
▀▀▀▀▀▀ Gemini API key
▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low)
▄▀▀ ▀▀▄ ~
▄▀▀ ▀▀▄
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
>
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
? for shortcutsGemini 3.7 Flash · low␍[?25h[?25l/
> /add-dir  Add a directory to the workspace
/agents List available custom agents
/artifact View and review artifacts
/btw Ask a side question without interrupting the current task
/changelog Show release notes and changes
 ↓ 50 more

↑/↓ Navigate · enter Select · tab Complete
 Gemini 3.7 Flash · low␍[?25h[?25l
esc to cancel[?25h[>4m[=0;1u
[?2004l[0 q
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-09-11T04:35:06.866Z",
"platform": "darwin",
"command": ["agy"],
"cols": 120,
"rows": 40,
"note": "agy TUI 1.2.0; /model picker opened then dismissed with esc, settled before stop",
"exitCode": 0
}
@@ -0,0 +1,54 @@
[?2026$p[?2027$p[?5W[?2004h[>4;2m[=1;1u[?u[0 q␍
▄▀▀▄ Antigravity CLI 1.2.0
▀▀▀▀▀▀ Gemini API key
▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low)
▄▀▀ ▀▀▄ ~
▄▀▀ ▀▀▄
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
>
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
? for shortcutsGemini 3.7 Flash · low␍[?25h[?25l/mod
> /model Set a model, or run a single prompt on another model
/permissioned-github Guidelines for interacting with GitHub and request permissions from the user when commands f...

↑/↓ Navigate · enter Select · tab Complete
esc to cancelGemini 3.7 Flash · low␍[?25h[?25l
/model


↑/↓ Navigate · enter Select · tab Complete
esc to cancelGemini 3.7 Flash · low␍[?25h[?25l[0 q
Switch Model
Gemini 3.8 Flash
> Gemini 3.7 Flash (current)
Gemini 3.6 Flash
Gemini 3.1 Pro

Effort ◂  ◉──────────────○──────────────○  ▸
  low  medium high 
 Faster responses, lighter reasoning — great for simpler tasks

Keyboard: ↑/↓ Navigate ←/→ Effort enter Select esc Go Back

 Gemini 3.7 Flash · low␍[0 q> /model
 ⎿ Exited /model command

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
>
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Gemini 3.7 Flash · low␍[?25h[?25l
? for shortcuts[?25h[>4m[=0;1u
[?2004l[0 q
Resume with -c (or command below):
agy --conversation=00000000-0000-4000-8000-000000000000
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-09-11T04:34:10.855Z",
"platform": "darwin",
"command": ["agy"],
"cols": 120,
"rows": 40,
"note": "agy TUI 1.2.0; /model picker live, unanswered, killed while it owns the screen",
"exitCode": 0
}
@@ -0,0 +1,56 @@
[?2026$p[?2027$p[?5W[?2004h[>4;2m[=1;1u[?u[0 q␍
▄▀▀▄ Antigravity CLI 1.2.0
▀▀▀▀▀▀ Gemini API key
▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low)
▄▀▀ ▀▀▄ ~
▄▀▀ ▀▀▄
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
>
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
? for shortcutsGemini 3.7 Flash · low␍[?25h[?25l/mo
> /model Set a model, or run a single prompt on another model
/migrate-workflows Automatically migrate legacy workflows to modern skills across global and workspace configur...
/permissions Manage tool permissions
/agy-customizations Comprehensive guide and reference for the Antigravity Customization System. Use to explain h...
/permissioned-github Guidelines for interacting with GitHub and request permissions from the user when commands f...

↑/↓ Navigate · enter Select · tab Complete
? for shortcutsGemini 3.7 Flash · low␍[?25h[?25l
/model


↑/↓ Navigate · enter Select · tab Complete
esc to cancelGemini 3.7 Flash · low␍[?25h[?25l[0 q
Switch Model
> Gemini 3.8 Flash
Gemini 3.7 Flash (current)
Gemini 3.6 Flash
Gemini 3.1 Pro

Effort ◂  ●━━━━━━━━━━━━━━◉──────────────○  ▸
  low  medium  high 
 Balanced speed and reasoning quality for most tasks

Keyboard: ↑/↓ Navigate ←/→ Effort enter Select esc Go Back

? for shortcutsGemini 3.7 Flash · low␍ Gemini 3.8 Flash
> Gemini 3.7 Flash
◂  ◉──────────────○
 low  medium 
Faster responses, lighter reasoning — great for simpler tasks
 G[>4m[=0;1u␍[?25h[?2004l
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-09-11T04:35:20.989Z",
"platform": "darwin",
"command": ["agy"],
"cols": 120,
"rows": 40,
"note": "agy TUI 1.2.0; workspace trust dialog live and unanswered in a throwaway untrusted directory",
"exitCode": 0
}
@@ -0,0 +1,12 @@
[?2026$p[?2027$p[>4m[=0;1u[?1049h[?25l[?5W[?2004h[>4;2m[=1;1u[?uAccessing workspace:
/private/tmp/agy-trust-scratch-77950
Do you trust the contents of this project?
Antigravity CLI requires permission to read, edit, and execute files here.
> Yes, I trust this folder
No, exit
↑/↓ Navigate · enter ConfirmGemini 3.7 Flash · low[>4m[=0;1u␍[?1049l[?25h[?2004l
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-09-11T04:33:34.954Z",
"platform": "darwin",
"command": ["agy"],
"cols": 120,
"rows": 40,
"note": "same session as antigravity-ready-api-key-gemini-model but with AGY_CLI_HIDE_ACCOUNT_INFO=1",
"exitCode": 0
}
@@ -0,0 +1,13 @@
[?2026$p[?2027$p[?5W[?2004h[>4;2m[=1;1u[?u[0 q␍
▄▀▀▄ Antigravity CLI 1.2.0
▀▀▀▀▀▀ Gemini 3.7 Flash (Low)
▀▀▀▀▀▀▀▀ ~
▄▀▀ ▀▀▄
▄▀▀ ▀▀▄
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
>
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
? for shortcutsGemini 3.7 Flash · low␍[?25h[>4m[=0;1u
[?2004l[0 q
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-09-11T04:33:14.819Z",
"platform": "darwin",
"command": ["agy"],
"cols": 120,
"rows": 40,
"note": "agy binary 1.1.25, TUI banner 1.2.0; Gemini API key identity (no OAuth sign-in); model Gemini 3.7 Flash (Low); workspace ~",
"exitCode": 0
}
@@ -0,0 +1,13 @@
[?2026$p[?2027$p[?5W[?2004h[>4;2m[=1;1u[?u[0 q␍
▄▀▀▄ Antigravity CLI 1.2.0
▀▀▀▀▀▀ Gemini API key
▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low)
▄▀▀ ▀▀▄ ~
▄▀▀ ▀▀▄
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
>
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
? for shortcutsGemini 3.7 Flash · low␍[?25h[>4m[=0;1u
[?2004l[0 q
@@ -0,0 +1,79 @@
// One pane builder for every suite that replays a captured agent transcript through the runtime.
import { vi } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
const TRANSCRIPT_PANE_LEAF_ID = '11111111-1111-4111-8111-111111111111'
const TRANSCRIPT_PANE_TAB_ID = 'tab-1'
const TRANSCRIPT_PANE_WORKTREE_ID = 'wt-1'
export const TRANSCRIPT_PANE_PTY_ID = 'pty-1'
export type TranscriptPaneOptions = {
paneTitle: string
foregroundProcess: string | null
data: string
/** Set for a pane whose PTY lives on an SSH host or WSL distro rather than locally. */
connectionId?: string
/** Simulates a PTY controller whose foreground probe never settles. */
foregroundProbeHangs?: boolean
onForegroundProbe?: () => void
}
export async function createTranscriptPane(
options: TranscriptPaneOptions
): Promise<{ runtime: OrcaRuntimeService; handle: string }> {
const runtime = new OrcaRuntimeService(null)
const internals = runtime as unknown as {
resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise<unknown>
}
vi.spyOn(internals, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({
id: TRANSCRIPT_PANE_WORKTREE_ID,
path: '/repo/app',
connectionId: options.connectionId ?? null,
repo: null,
folderWorkspace: null
})
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: TRANSCRIPT_PANE_PTY_ID, incarnationId: 'inc-1' }),
write: () => true,
kill: () => true,
getForegroundProcess: (): Promise<string | null> => {
options.onForegroundProbe?.()
return options.foregroundProbeHangs === true
? new Promise<string | null>(() => {})
: Promise.resolve(options.foregroundProcess)
}
})
const terminal = await runtime.createTerminal(`id:${TRANSCRIPT_PANE_WORKTREE_ID}`, {
tabId: TRANSCRIPT_PANE_TAB_ID,
leafId: TRANSCRIPT_PANE_LEAF_ID,
title: 'Terminal'
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: TRANSCRIPT_PANE_TAB_ID,
worktreeId: TRANSCRIPT_PANE_WORKTREE_ID,
title: 'Terminal',
activeLeafId: TRANSCRIPT_PANE_LEAF_ID,
layout: null
}
],
leaves: [
{
tabId: TRANSCRIPT_PANE_TAB_ID,
worktreeId: TRANSCRIPT_PANE_WORKTREE_ID,
leafId: TRANSCRIPT_PANE_LEAF_ID,
paneRuntimeId: 1,
ptyId: TRANSCRIPT_PANE_PTY_ID,
paneTitle: options.paneTitle
}
]
})
// Why the guard: a restore seed is only applied to a never-written record, so the restore
// cases must not write an empty chunk first.
if (options.data.length > 0) {
runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, options.data, Date.now())
}
return { runtime, handle: terminal.handle }
}
@@ -0,0 +1,281 @@
/**
* Pins Antigravity readiness to captured transcripts instead of hand-written fixtures.
*
* Five detector attempts were tuned against a five-line screen someone typed from memory, and
* three of them shipped worse behaviour than the bug they replaced. Nothing here asserts what
* Antigravity prints: the transcripts do. Six are recorded from a live `agy`; the rest name
* themselves as skipped until someone can reach them.
*
* Four cases are pinned as KNOWN DEFECT: on real output the shipped detector refuses the ready
* screen and accepts the live model picker. Those assert what it does, not what it should.
*
* Capture protocol: docs/reference/agent-pty-transcript-capture.md
* What each transcript decides: docs/reference/antigravity-readiness-evidence.md
*/
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { createTranscriptPane } from './agent-transcript-pane-test-harness'
import { extractLastOscTitle } from '../../shared/osc-title-extraction'
vi.mock('electron', () => ({
BrowserWindow: { fromId: vi.fn(() => null) },
webContents: { fromId: vi.fn(() => null) },
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
app: { getPath: vi.fn(() => '/tmp') }
}))
const FIXTURE_DIR = join(__dirname, '__fixtures__')
const EVIDENCE_DOC = join(
__dirname,
'..',
'..',
'..',
'docs',
'reference',
'antigravity-readiness-evidence.md'
)
// Why asymmetric: a ready verdict has to survive the settle window, while a refusal only has to
// hold for one poll. Keeping the refusal short keeps seven transcripts off the suite's clock.
const READY_TIMEOUT_MS = 2_000
const REFUSAL_TIMEOUT_MS = 600
/** Antigravity's binary, as Orca launches and probes it (`tui-agent-config.ts` detectCmd). */
const ANTIGRAVITY_COMMAND = 'agy'
// String.fromCharCode, not a literal: the formatter rewrites an escape sequence into a raw
// control byte in source, which is unreadable and survives badly in diffs.
const ESC = String.fromCharCode(27)
type TranscriptCase = {
/** Fixture basename; `<name>.txt` under `__fixtures__/`. */
name: string
/** Capture in docs/reference/antigravity-readiness-evidence.md. */
capture: string
what: string
/** What a correct detector must answer. Not what the shipped one answers. */
expectReady: boolean
/**
* Set where the shipped detector contradicts the transcript. The case then runs inverted, so
* CI pins the defect instead of going permanently red — and flips to failing the moment
* someone fixes it, which is exactly when these expectations need re-reading.
*/
knownDefect?: string
}
const TRANSCRIPTS: readonly TranscriptCase[] = [
{
name: 'antigravity-ready-api-key-gemini-model',
capture: 'B',
what: 'ready screen, API-key identity — the account row reads "Gemini API key", not an email',
expectReady: true,
knownDefect: 'refused: the model row never starts a line, the logo shares it'
},
{
name: 'antigravity-ready-account-info-hidden',
capture: 'B',
what: 'ready screen with AGY_CLI_HIDE_ACCOUNT_INFO=1 — no account row at all',
expectReady: true,
knownDefect: 'refused: same line-start defect, and no account row exists to require'
},
{
name: 'antigravity-dialog-trust-workspace',
capture: 'C',
what: 'workspace trust dialog owning the screen',
expectReady: false
},
{
name: 'antigravity-dialog-model-picker',
capture: 'C',
what: 'model picker owning the screen',
expectReady: false,
knownDefect: "accepted: the picker's own `Gemini 3.x Flash` rows satisfy the model rule"
},
{
name: 'antigravity-dialog-command-palette',
capture: 'C',
what: 'slash-command palette owning the screen',
expectReady: false
},
{
name: 'antigravity-busy-mid-turn',
capture: 'E',
what: 'mid-turn, spinner live — the pane is working, not waiting for a prompt',
expectReady: false
},
{
// Expected ready because the turn is over and the composer is back on screen. The captured
// turn ends in a backend error, which is the only ending this account's key can produce.
name: 'antigravity-busy-turn-ended',
capture: 'E',
what: 'the turn has ended and the composer has returned, process still alive',
expectReady: true,
knownDefect: 'refused: the retained tail ends on the error block, with no composer row in it'
},
{
name: 'antigravity-dialog-dismissed',
capture: 'D',
what: 'the screen immediately after the model picker is dismissed',
expectReady: true,
knownDefect: 'refused: the banner is not reprinted and no model row starts a line'
},
// Not captured: this machine's agy has no OAuth session and offers only Gemini models, and
// reaching the rest would mean signing the operator out or deleting their config. See
// docs/reference/antigravity-readiness-evidence.md § What could not be captured.
{
name: 'antigravity-ready-business-non-gemini',
capture: 'A',
what: 'ready screen, Business account, non-Gemini model',
expectReady: true
},
{
name: 'antigravity-dialog-sign-in',
capture: 'C',
what: 'sign-in dialog owning the screen',
expectReady: false
},
{
name: 'antigravity-dialog-theme-picker',
capture: 'C',
what: 'theme picker owning the screen',
expectReady: false
},
{
name: 'antigravity-dialog-privacy-notice',
capture: 'C',
what: 'privacy notice owning the screen',
expectReady: false
},
{
name: 'antigravity-dialog-update-banner',
capture: 'C',
what: 'update banner owning the screen',
expectReady: false
}
]
function fixturePath(name: string): string {
return join(FIXTURE_DIR, `${name}.txt`)
}
/**
* A `tui-idle` wait ends three ways, and only one of them is readiness: it resolves satisfied, it
* resolves unsatisfied with a blocked reason, or it rejects with `timeout` because nothing ever
* looked ready. The orchestrator treats the last two identically — no prompt is delivered — so
* they are both `ready: false` here. This is the shape `worker-start` sees.
*/
async function readinessVerdict(
transcript: string,
timeoutMs: number
): Promise<{ ready: boolean; blockedReason: unknown; outcome: string }> {
const { runtime, handle } = await createTranscriptPane({
// Why the transcript's own title: every attempt guessed at Antigravity's title. A raw
// capture carries the OSC bytes, so the pane wears whatever the CLI actually set.
paneTitle: extractLastOscTitle(transcript) ?? ANTIGRAVITY_COMMAND,
foregroundProcess: ANTIGRAVITY_COMMAND,
data: transcript
})
try {
const result = (await runtime.waitForTerminal(handle, {
condition: 'tui-idle',
timeoutMs
})) as { satisfied?: boolean; blockedReason?: unknown }
return {
ready: result.satisfied === true,
blockedReason: result.blockedReason ?? null,
outcome: result.satisfied === true ? 'satisfied' : 'unsatisfied'
}
} catch (error) {
return { ready: false, blockedReason: null, outcome: `rejected: ${String(error)}` }
}
}
describe('Antigravity readiness, decided by captured transcripts', () => {
for (const transcript of TRANSCRIPTS) {
const path = fixturePath(transcript.name)
const captured = existsSync(path)
const label = `capture ${transcript.capture}: ${transcript.what}`
// A pinned defect asserts what the detector DOES, so CI is honest rather than permanently
// red; fixing the detector flips this case to failing, which is when these expectations
// need re-reading. The correct answer stays in `expectReady` and in the test's name.
const shipped =
transcript.knownDefect === undefined ? transcript.expectReady : !transcript.expectReady
const verdictName =
transcript.knownDefect === undefined
? `${label} → ${transcript.expectReady ? 'ready' : 'not ready'}`
: `${label} → must be ${transcript.expectReady ? 'ready' : 'not ready'}; KNOWN DEFECT, ${transcript.knownDefect}`
it.skipIf(!captured)(
verdictName,
async () => {
// A refusal only has to hold for one poll; a ready verdict has to survive the settle
// window. Keeping the refusal short keeps eleven transcripts off the suite's clock.
const verdict = await readinessVerdict(
readFileSync(path, 'utf8'),
transcript.expectReady ? READY_TIMEOUT_MS : REFUSAL_TIMEOUT_MS
)
// A silent dialog carries no blocked-signal wording, so the assertion is only that Orca
// does not call the pane ready and type a prompt into a dialog that owns the screen.
expect({ ready: verdict.ready, outcome: verdict.outcome }).toMatchObject({
ready: shipped
})
},
READY_TIMEOUT_MS + 10_000
)
it.skipIf(!captured)(`${label} was captured raw, not pasted from a rendered screen`, () => {
const text = readFileSync(path, 'utf8')
// Why: a transcript with no escape bytes went through a terminal's renderer and a
// human's clipboard. It cannot answer what the caret or chrome looked like.
expect(text).toContain(ESC)
})
}
it('documents every transcript the detector is allowed to depend on', () => {
// Why a test: the doc is the operator's checklist. A name that drifts out of it is a
// transcript nobody will capture, and a case that silently skips forever.
const doc = readFileSync(EVIDENCE_DOC, 'utf8')
for (const transcript of TRANSCRIPTS) {
expect(doc).toContain(`${transcript.name}.txt`)
}
})
it('reports how much evidence exists, so a fully skipped run is visible', () => {
const missing = TRANSCRIPTS.filter(
(transcript) => !existsSync(fixturePath(transcript.name))
).map((transcript) => `${transcript.name}.txt`)
if (missing.length > 0) {
console.info(
`Antigravity transcripts: ${TRANSCRIPTS.length - missing.length}/${TRANSCRIPTS.length} captured. Missing: ${missing.join(', ')}`
)
}
expect(missing.length).toBeLessThanOrEqual(TRANSCRIPTS.length)
})
})
describe('scaffold self-check', () => {
// Why these two live here: when a transcript lands and fails, the failure has to mean the
// capture disagreed with the detector — not that the harness or the timeouts are broken.
// Neither case is evidence about Antigravity; both are shapes the current detector already
// decides, used only to prove the plumbing reaches a verdict.
it('reaches a ready verdict through the harness', async () => {
const verdict = await readinessVerdict(
[
'Antigravity CLI 1.0.3',
'user@example.com (Antigravity Business)',
'Gemini 3.5 Flash (High)',
'~/orca/workspaces/orca/agy-dispatch-issue',
'>'
].join('\n'),
READY_TIMEOUT_MS
)
expect(verdict.ready).toBe(true)
})
it('reaches a not-ready verdict through the harness', async () => {
const verdict = await readinessVerdict(
'Do you trust this workspace directory?\nPress t to trust\n',
REFUSAL_TIMEOUT_MS
)
expect(verdict.ready).toBe(false)
})
})
@@ -149,13 +149,17 @@ export class OrcaRuntimeWithPtyForegroundProcessReads extends OrcaRuntimeWithSta
...(allowUnverifiedStop ? { allowUnverifiedStop: true } : {}),
...(connectionId ? { includeLocalRegistry: false } : {})
})
// Structured sessions are counted here too, mirroring the IPC path: closing a user's chat is
// now an ordinary outcome of this verb, and a removal that closed one but no PTY logged nothing.
const structuredStopped = teardownResult.structuredStopped ?? 0
const total =
teardownResult.runtimeStopped +
teardownResult.providerStopped +
teardownResult.registryStopped
teardownResult.registryStopped +
structuredStopped
if (total > 0) {
console.info(
`[worktree-teardown] ${worktreeId} killed runtime=${teardownResult.runtimeStopped} provider=${teardownResult.providerStopped} registry=${teardownResult.registryStopped}`
`[worktree-teardown] ${worktreeId} killed runtime=${teardownResult.runtimeStopped} provider=${teardownResult.providerStopped} registry=${teardownResult.registryStopped} structured=${structuredStopped}`
)
}
}
@@ -0,0 +1,237 @@
/**
* The chat tab must survive a close that did not land.
*
* `closeStructuredAgentSessionChild` hides the tab BEFORE it issues the close, so every failure
* shape past that point used to leave the user's chat tab pulled out of the durable restore index
* for a session that is still running — a destructive operation that refused, and still took
* something away.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
const hostRef: { current: unknown } = { current: null }
vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', () => ({
getStructuredAgentSessionHost: () => hostRef.current
}))
const { closeStructuredAgentSessionChild } = await import('./structured-agent-session-close')
const SESSION = 'session-1'
function record(sessionId: string): AgentSessionRecord {
return {
sessionId,
provider: 'claude',
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'repo_1::/tmp/wt-a',
workspaceKind: 'folder'
},
lease: {
sessionId,
runtimeKind: 'native',
claimStatus: 'live',
handoffStage: null,
runtimeFence: 1,
deathEvidence: null
}
} as unknown as AgentSessionRecord
}
type HostOptions = {
/** Sessions the host keeps holding through a close, so the post-close observation is `live`. */
stuck?: boolean
/** Rejects the close, without the child going. */
closeThrows?: Error
/** The child dies and is recorded dead, but the close then fails past that proof. */
settledThenThrows?: boolean
/** Rejects the visibility write itself, so the hide never lands. */
visibilityThrows?: Error
/** Sessions already in the persisted visible-tab index. */
visible?: string[]
/** Blows up the index read, so the rollback cannot prove the tab was ever visible. */
indexThrows?: boolean
}
function installHost(options: HostOptions = {}) {
const entry = record(SESSION)
const held = new Set([SESSION])
const visible = new Set(options.visible ?? [SESSION])
const setSessionTabVisibility = vi.fn(async (sessionId: string, isVisible: boolean) => {
if (options.visibilityThrows) {
throw options.visibilityThrows
}
if (isVisible) {
visible.add(sessionId)
} else {
visible.delete(sessionId)
}
})
const close = vi.fn(async (sessionId: string) => {
if (options.closeThrows) {
throw options.closeThrows
}
if (options.stuck) {
return
}
held.delete(sessionId)
entry.lease.claimStatus = 'released'
entry.lease.deathEvidence = { kind: 'exit-observed', detail: 'closed', observedAt: 1 }
if (options.settledThenThrows) {
throw new Error('the event sink could not be flushed')
}
})
hostRef.current = {
deps: { store: { getRecord: (id: string) => (id === SESSION ? entry : null) } },
hasSession: (sessionId: string) => held.has(sessionId),
getPersistedVisibleSessionTabIndex: () => {
if (options.indexThrows) {
throw new Error('visible tab index unreadable')
}
return { present: true, sessionIds: [...visible] }
},
setSessionTabVisibility,
close
}
return { close, setSessionTabVisibility, visible }
}
describe('closeStructuredAgentSessionChild tab-visibility rollback', () => {
beforeEach(() => {
hostRef.current = null
vi.restoreAllMocks()
})
it('retires the tab and reports the close on the success path', async () => {
const host = installHost()
const retire = vi.fn(() => true)
const outcome = await closeStructuredAgentSessionChild(SESSION, {
runtime: {
retireStructuredAgentSessionTabFromSnapshot: retire
} as never
})
expect(outcome).toEqual({ stopped: true, closeAttempted: true })
expect(host.visible.has(SESSION)).toBe(false)
expect(retire).toHaveBeenCalledWith(SESSION)
// The hide is the only visibility write a settled close performs.
expect(host.setSessionTabVisibility.mock.calls).toEqual([[SESSION, false]])
})
it('restores the tab when the close throws and the child is still there', async () => {
const host = installHost({ closeThrows: new Error('provider round trip failed') })
const outcome = await closeStructuredAgentSessionChild(SESSION)
expect(outcome.stopped).toBe(false)
expect(outcome.closeAttempted).toBe(true)
expect(outcome.reason).toBe('provider round trip failed')
expect(host.visible.has(SESSION)).toBe(true)
expect(host.setSessionTabVisibility.mock.calls).toEqual([
[SESSION, false],
[SESSION, true]
])
})
it('restores the tab when the post-close observation is not `exited`', async () => {
const host = installHost({ stuck: true })
const outcome = await closeStructuredAgentSessionChild(SESSION)
expect(outcome.stopped).toBe(false)
expect(outcome.closeAttempted).toBe(true)
expect(host.visible.has(SESSION)).toBe(true)
expect(host.setSessionTabVisibility.mock.calls).toEqual([
[SESSION, false],
[SESSION, true]
])
})
it('leaves the tab retired when a close throws PAST a proven exit', async () => {
// `closeStructuredSessionsForWorktree` re-observes and counts this session closed; republishing
// the tab here would resurrect it at the next launch for a workspace that is gone.
const host = installHost({ settledThenThrows: true })
const outcome = await closeStructuredAgentSessionChild(SESSION)
expect(outcome.stopped).toBe(false)
expect(host.visible.has(SESSION)).toBe(false)
expect(host.setSessionTabVisibility.mock.calls).toEqual([[SESSION, false]])
})
it('does not put the tab back when the caller is discarding the workspace anyway', async () => {
// Worktree teardown passes this off for a removal that cannot refuse — force, and the
// folder-workspace paths. A tab put back there is a durable reference to a workspace that is
// about to be gone, so it republishes the chat at the next launch pointing at it.
const host = installHost({ stuck: true })
const outcome = await closeStructuredAgentSessionChild(SESSION, {
restoreTabOnUnprovenClose: false
})
expect(outcome.stopped).toBe(false)
expect(host.visible.has(SESSION)).toBe(false)
expect(host.setSessionTabVisibility.mock.calls).toEqual([[SESSION, false]])
})
it('does not publish a tab for a session that was already hidden', async () => {
const host = installHost({ closeThrows: new Error('provider round trip failed'), visible: [] })
await closeStructuredAgentSessionChild(SESSION)
expect(host.visible.has(SESSION)).toBe(false)
expect(host.setSessionTabVisibility.mock.calls).toEqual([[SESSION, false]])
})
it('does not roll back a visibility write that never landed', async () => {
const host = installHost({ visibilityThrows: new Error('visibility write failed') })
const outcome = await closeStructuredAgentSessionChild(SESSION)
expect(outcome).toEqual({
stopped: false,
closeAttempted: false,
reason: 'visibility write failed'
})
expect(host.close).not.toHaveBeenCalled()
expect(host.setSessionTabVisibility.mock.calls).toEqual([[SESSION, false]])
})
it('keeps the original failure when the restore itself throws', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const host = installHost({ stuck: true })
host.setSessionTabVisibility.mockImplementation(async (_sessionId, isVisible) => {
if (isVisible) {
throw new Error('agent_session_identity_required')
}
})
const outcome = await closeStructuredAgentSessionChild(SESSION)
expect(outcome.stopped).toBe(false)
expect(outcome.closeAttempted).toBe(true)
expect(outcome.reason).not.toContain('agent_session_identity_required')
expect(warn).toHaveBeenCalled()
})
it('claims nothing when the visible-tab index cannot be read', async () => {
const host = installHost({ indexThrows: true, closeThrows: new Error('boom') })
await closeStructuredAgentSessionChild(SESSION)
expect(host.setSessionTabVisibility.mock.calls).toEqual([[SESSION, false]])
})
it('reports no close attempt when no host is installed', async () => {
hostRef.current = null
const outcome = await closeStructuredAgentSessionChild(SESSION)
expect(outcome.stopped).toBe(false)
expect(outcome.closeAttempted).toBe(false)
})
})
@@ -11,6 +11,7 @@
* longer live is proven gone. Anything else is retained rather than settled.
*/
import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host'
import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
import type { OrcaRuntimeService } from './orca-runtime'
import { retireSettledStructuredWorkerTab } from './structured-agent-session-tab-retirement'
@@ -35,6 +36,15 @@ export type StructuredAgentSessionCloseOptions = {
* keep the child un-evictable for the life of the app. Every settlement has to reach it.
*/
afterClose?: () => void
/**
* Whether an unproven close may put the chat tab back in the durable restore index.
*
* On by default, which is the retryable case: a stop that refused and still took the user's tab
* away is the loss the rollback exists to undo. A caller that will discard the WORKSPACE
* whatever this close reports passes false — a tab put back there is a durable reference to a
* workspace about to be gone, and it republishes the chat at the next launch pointing at it.
*/
restoreTabOnUnprovenClose?: boolean
}
export async function closeStructuredAgentSessionChild(
@@ -50,6 +60,11 @@ export async function closeStructuredAgentSessionChild(
reason: 'The structured agent-session host is not installed; no session was closed.'
}
}
// Read BEFORE the hide, so a rollback puts the tab back exactly as it was. Restoring
// unconditionally would publish a tab for a session that was already hidden — a worker started
// without a chat tab, or one the user had closed — which is a new side effect, not an undo.
const restoreTabIfCloseFails =
options.restoreTabOnUnprovenClose !== false && readPersistedTabVisibility(host, sessionId)
// Set only once the close is actually issued: `setSessionTabVisibility` throwing first leaves a
// running child, and a receipt that still said `closed_agent_terminal` for it would be the
// close-that-never-happened this flag exists to rule out.
@@ -59,6 +74,11 @@ export async function closeStructuredAgentSessionChild(
closeAttempted = true
await host.close(sessionId)
} catch (error) {
// Only `closeAttempted` proves the hide landed: the store transaction restores its own state on
// failure, so a `setSessionTabVisibility` that threw hid nothing and has nothing to undo.
if (closeAttempted) {
await restorePersistedTabVisibility(host, sessionId, restoreTabIfCloseFails)
}
return {
stopped: false,
closeAttempted,
@@ -68,6 +88,7 @@ export async function closeStructuredAgentSessionChild(
options.afterClose?.()
const observation = observeStructuredWorker({ sessionId })
if (observation.status !== 'exited') {
await restorePersistedTabVisibility(host, sessionId, restoreTabIfCloseFails)
return {
stopped: false,
closeAttempted: true,
@@ -79,3 +100,52 @@ export async function closeStructuredAgentSessionChild(
retireSettledStructuredWorkerTab(sessionId, options.runtime)
return { stopped: true, closeAttempted: true }
}
function readPersistedTabVisibility(host: StructuredAgentSessionHost, sessionId: string): boolean {
try {
return host.getPersistedVisibleSessionTabIndex?.().sessionIds.includes(sessionId) ?? false
} catch {
// Unreadable index: claim nothing. A rollback that cannot prove the tab was visible must not
// publish one, for the same reason the read exists at all.
return false
}
}
/**
* Puts the chat tab back after a close that did not settle.
*
* The hide is the one visible side effect this function performs before the destructive step, so a
* failed close that kept it left the user's chat tab gone from the durable restore index — the
* conversation survived under `userData`, but nothing brought the tab back at the next launch.
*
* Re-observed first rather than restored outright: a close can throw PAST its own proof and still
* have taken the child with it, and `closeStructuredSessionsForWorktree` reads exactly that,
* counting such a session closed and retiring its tab. Republishing there would resurrect a tab for
* a session that is demonstrably gone, at the next launch, pointing at a deleted workspace.
*
* That observation NARROWS the window; it does not close it. This one and the sweep's are taken a
* store write apart, so a child that dies in between is unverifiable here and exited there — which
* is why the sweep re-drops the tab reference when it takes that proof. Do not delete either half
* on the strength of the other.
*
* Never throws: the caller's `reason` is what the user is asked to act on, and a rollback failure
* must not replace it. `agent_session_identity_required` is the expected one — the record can be
* gone by now, which is itself the exit this restore is declining to undo.
*/
async function restorePersistedTabVisibility(
host: StructuredAgentSessionHost,
sessionId: string,
restoreTab: boolean
): Promise<void> {
if (!restoreTab || observeStructuredWorker({ sessionId }).status === 'exited') {
return
}
try {
await host.setSessionTabVisibility?.(sessionId, true)
} catch (error) {
console.warn(
`[structured-session-close] could not restore the chat tab for ${sessionId} after a failed close`,
error
)
}
}
@@ -8,18 +8,31 @@ vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', (
}))
const { killAllProcessesForWorktree } = await import('./worktree-teardown')
const { classifyWorktreeForceDeleteReason } = await import('../../shared/worktree/removal')
const {
classifyWorktreeForceDeleteReason,
isProvenLiveStructuredSessionRemovalError,
isUnstoppedPtyRemovalError
} = await import('../../shared/worktree/removal')
const { listLiveStructuredSessionsForWorktree } =
await import('./structured-session-worktree-teardown')
const WORKTREE = 'repo_1::/tmp/wt-a'
const OTHER_WORKTREE = 'repo_1::/tmp/wt-b'
function record(sessionId: string, workspaceId: string): AgentSessionRecord {
function record(
sessionId: string,
workspaceId: string,
options: { provider?: 'claude' | 'codex'; executionHostId?: string } = {}
): AgentSessionRecord {
return {
sessionId,
provider: 'claude',
location: { executionHostId: 'local', wslDistro: null, workspaceId, workspaceKind: 'folder' },
provider: options.provider ?? 'claude',
location: {
executionHostId: options.executionHostId ?? 'local',
wslDistro: null,
workspaceId,
workspaceKind: 'folder'
},
lease: {
sessionId,
runtimeKind: 'native',
@@ -33,24 +46,66 @@ function record(sessionId: string, workspaceId: string): AgentSessionRecord {
function installHost(options: {
records: AgentSessionRecord[]
/** Sessions the host still holds; a close removes one unless it is listed as stuck. */
/** Sessions the host keeps holding through a close, so the post-close observation is `live`. */
stuck?: Set<string>
}): { closed: string[] } {
/** Sessions the host drops without death evidence, so the observation is `unverifiable`. */
unverifiable?: Set<string>
/** Sessions whose child dies and is recorded dead, but whose close then fails past that point. */
settledThenThrows?: Set<string>
/** Blocks every close, to exercise the shared sweep budget without fake timers. */
closeGate?: Promise<void>
/** Blocks ONE session's close, so the serial loop can be caught part-way through. */
closeGates?: Record<string, Promise<void>>
/** Sessions in the persisted visible-tab index, so a rollback has something to put back. */
visible?: string[]
/**
* Sessions whose death evidence lands DURING the close's tab-restore write.
*
* `setSessionTabVisibility` is a store transaction — a real disk write — so the close's own
* observation and the sweep's re-read straddle it and can disagree about the same session.
*/
exitsDuringTabRestore?: Set<string>
}): { closed: string[]; visible: Set<string> } {
const held = new Set(options.records.map((entry) => entry.sessionId))
const closed: string[] = []
const visible = new Set(options.visible ?? [])
const recordExit = (sessionId: string): void => {
const entry = options.records.find((candidate) => candidate.sessionId === sessionId)
if (entry) {
entry.lease.claimStatus = 'released'
entry.lease.deathEvidence = { kind: 'exit-observed', detail: 'closed', observedAt: 1 }
}
}
hostRef.current = {
deps: { store: { listRecords: () => options.records, getRecord: () => null } },
hasSession: (sessionId: string) => held.has(sessionId),
setSessionTabVisibility: async () => {},
getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: [...visible] }),
setSessionTabVisibility: async (sessionId: string, isVisible: boolean) => {
if (!isVisible) {
visible.delete(sessionId)
return
}
if (options.exitsDuringTabRestore?.has(sessionId)) {
recordExit(sessionId)
}
visible.add(sessionId)
},
close: async (sessionId: string) => {
closed.push(sessionId)
if (!options.stuck?.has(sessionId)) {
held.delete(sessionId)
const record = options.records.find((entry) => entry.sessionId === sessionId)
if (record) {
record.lease.claimStatus = 'released'
record.lease.deathEvidence = { kind: 'exit-observed', detail: 'closed', observedAt: 1 }
}
await options.closeGate
await options.closeGates?.[sessionId]
if (options.stuck?.has(sessionId)) {
return
}
held.delete(sessionId)
if (options.unverifiable?.has(sessionId)) {
return
}
if (!options.exitsDuringTabRestore?.has(sessionId)) {
recordExit(sessionId)
}
if (options.settledThenThrows?.has(sessionId)) {
throw new Error('the event sink could not be flushed')
}
}
}
@@ -59,7 +114,7 @@ function installHost(options: {
hostRef.current as { deps: { store: { getRecord: (id: string) => unknown } } }
).deps.store.getRecord = (sessionId: string) =>
options.records.find((entry) => entry.sessionId === sessionId) ?? null
return { closed }
return { closed, visible }
}
const localProvider = {
@@ -67,7 +122,7 @@ const localProvider = {
shutdown: async () => {}
} as never
function destructiveDeps(extra: { allowUnverifiedStop?: boolean } = {}) {
function destructiveDeps(extra: { allowUnverifiedStop?: boolean; timeoutMs?: number } = {}) {
return {
localProvider,
requirePhysicalStop: true,
@@ -77,6 +132,15 @@ function destructiveDeps(extra: { allowUnverifiedStop?: boolean } = {}) {
}
}
/** The structured sweep's own warn — a forced removal can emit a PTY-sweep one onto the same spy. */
function structuredSessionWarning(warn: { mock: { calls: unknown[][] } }): string {
return (
warn.mock.calls
.map((call) => String(call[0]))
.find((message) => message.includes('agent session')) ?? ''
)
}
describe('worktree teardown and structured agent sessions', () => {
beforeEach(() => {
hostRef.current = null
@@ -84,24 +148,98 @@ describe('worktree teardown and structured agent sessions', () => {
it('finds sessions by workspace, and ignores a sibling worktree', () => {
installHost({ records: [record('s1', WORKTREE), record('s2', OTHER_WORKTREE)] })
expect(listLiveStructuredSessionsForWorktree(WORKTREE)).toEqual([
expect(listLiveStructuredSessionsForWorktree(WORKTREE, {})).toEqual([
{ sessionId: 's1', agent: 'claude' }
])
})
it('refuses a destructive removal rather than deleting the checkout under a live child', async () => {
// The defect this pins: all three PTY sweeps enumerate leaves, provider sessions and the local
// registry, and a structured session is on NONE of them. Every sweep answered zero, nothing
// errored, and removal proceeded — leaving the provider child running with its `cwd` deleted
// and the dispatch still reporting the worker live and exact.
installHost({ records: [record('s1', WORKTREE)] })
it('closes a live session on an ordinary removal instead of refusing it', async () => {
// The defect this pins, and the reason the guard is not simply deleted: all three PTY sweeps
// enumerate leaves, provider sessions and the local registry, and a structured session is on
// NONE of them, so removal used to proceed leaving the provider child running with its `cwd`
// deleted. The stop belongs on the ordinary path — the same one that kills a terminal running
// the same agent — so an idle chat is no harder to delete than that terminal.
const host = installHost({ records: [record('s1', WORKTREE)] })
await expect(killAllProcessesForWorktree(WORKTREE, destructiveDeps())).resolves.toMatchObject({
structuredStopped: 1
})
expect(host.closed).toEqual(['s1'])
})
it('refuses only when the close does not settle', async () => {
installHost({ records: [record('s1', WORKTREE)], stuck: new Set(['s1']) })
await expect(killAllProcessesForWorktree(WORKTREE, destructiveDeps())).rejects.toThrow(
/1 running agent session/
/still live: 1 agent session \(claude\)/
)
})
it('puts the chat tab back when the removal refuses over the session', async () => {
// The workspace survives a refusal, so the tab has to survive it too: a destructive operation
// that refused and still took the user's chat tab away is the loss the rollback exists to undo.
const host = installHost({
records: [record('s1', WORKTREE)],
stuck: new Set(['s1']),
visible: ['s1']
})
await expect(killAllProcessesForWorktree(WORKTREE, destructiveDeps())).rejects.toThrow(
/still live: 1 agent session \(claude\)/
)
expect([...host.visible]).toEqual(['s1'])
})
it('leaves the chat tab dropped when a forced removal deletes the workspace anyway', async () => {
// The other half of the same rollback. Force does not refuse — it warns and goes on to delete
// the checkout — so putting the tab back leaves a DURABLE reference to a workspace that is
// about to be gone, which republishes the chat at the next launch pointing at a deleted
// worktree: the exact outcome this whole sweep exists to remove.
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const host = installHost({
records: [record('s1', WORKTREE)],
stuck: new Set(['s1']),
visible: ['s1']
})
await killAllProcessesForWorktree(WORKTREE, destructiveDeps({ allowUnverifiedStop: true }))
expect([...host.visible]).toEqual([])
warn.mockRestore()
})
it('leaves the chat tab dropped for a folder-workspace removal, which never refuses', async () => {
// Same reasoning without the force waiver: this caller cannot refuse at all, so the workspace
// is forgotten whatever the close reports.
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const host = installHost({
records: [record('s1', WORKTREE)],
stuck: new Set(['s1']),
visible: ['s1']
})
await killAllProcessesForWorktree(WORKTREE, {
localProvider,
includeProviderInventory: false as const,
includeLocalRegistry: false as const,
closeStructuredSessions: true
})
expect([...host.visible]).toEqual([])
warn.mockRestore()
})
it('drops the chat tab for a session the sweep proves exited after the close gave up', async () => {
// `host.close` can return BEFORE the child's exit is recorded, so the close's own observation
// reads unverifiable and puts the tab back — and the sweep's re-read, one store write later,
// proves the exit and counts the session closed. The two observations straddle that write and
// can disagree; the tab must not survive the disagreement, because this removal proceeds.
const host = installHost({
records: [record('s1', WORKTREE)],
visible: ['s1'],
exitsDuringTabRestore: new Set(['s1'])
})
await expect(killAllProcessesForWorktree(WORKTREE, destructiveDeps())).resolves.toMatchObject({
structuredStopped: 1
})
expect([...host.visible]).toEqual([])
})
it('names the force escape hatch in the refusal, like the unstopped-PTY gate', async () => {
installHost({ records: [record('s1', WORKTREE)] })
installHost({ records: [record('s1', WORKTREE)], stuck: new Set(['s1']) })
await expect(killAllProcessesForWorktree(WORKTREE, destructiveDeps())).rejects.toThrow(/force/i)
})
@@ -109,7 +247,7 @@ describe('worktree teardown and structured agent sessions', () => {
// The #11960 dead end, and the shape this file's own comments warn about: the desktop
// affordance comes ONLY from the classifier, and an ordinary delete already passes force:true
// for the dirty-file skip — so a refusal with no matcher shows raw CLI wording with no button.
installHost({ records: [record('s1', WORKTREE)] })
installHost({ records: [record('s1', WORKTREE)], stuck: new Set(['s1']) })
const error = await killAllProcessesForWorktree(WORKTREE, destructiveDeps()).catch(
(thrown: Error) => thrown.message
)
@@ -123,12 +261,12 @@ describe('worktree teardown and structured agent sessions', () => {
// A session id is one tab-id hop from the random pane key that gates a worker's mailbox, and
// this string reaches CLI output and a desktop toast. A count and the providers are what a
// user deciding whether to force actually needs.
installHost({ records: [record('s1', WORKTREE)] })
installHost({ records: [record('s1', WORKTREE)], stuck: new Set(['s1']) })
const error = await killAllProcessesForWorktree(WORKTREE, destructiveDeps()).catch(
(thrown: Error) => thrown.message
)
expect(error).not.toContain('s1')
expect(error).toContain('1 running agent session')
expect(error).toContain('1 agent session (claude)')
})
it('closes best-effort for a folder-workspace removal, which requires no stop proof', async () => {
@@ -166,10 +304,33 @@ describe('worktree teardown and structured agent sessions', () => {
destructiveDeps({ allowUnverifiedStop: true })
)
expect(result.structuredStopped).toBeUndefined()
expect(warn).toHaveBeenCalledWith(expect.stringContaining('still attached'))
// The live arm of that record, carrying the verdict the refusal would have shown.
expect(structuredSessionWarning(warn)).toContain('still live: 1 agent session (claude)')
warn.mockRestore()
})
it('takes the proof when a failed close is re-observed as exited', async () => {
// `closeStructuredAgentSessionChild` reports `stopped: false` for anything that throws past its
// own observation, and for a record whose death evidence lands after it read. The re-read here
// can still PROVE the exit — refusing a delete over a child that is demonstrably gone is the
// defect this whole sweep exists to remove, so the proof has to win over the close's verdict.
const retired: string[] = []
const runtime = {
stopTerminalsForWorktree: async () => ({ stopped: 0 }),
retireStructuredAgentSessionTabFromSnapshot: (sessionId: string) => {
retired.push(sessionId)
return true
}
} as never
installHost({ records: [record('s1', WORKTREE)], settledThenThrows: new Set(['s1']) })
await expect(
killAllProcessesForWorktree(WORKTREE, { ...destructiveDeps(), runtime })
).resolves.toMatchObject({ structuredStopped: 1 })
// Retired here because the close gave up before its own retirement step, and a chat tab left
// behind re-attaches a released session pointing at a workspace that is about to be deleted.
expect(retired).toEqual(['s1'])
})
it('leaves the best-effort reconciliation paths alone', async () => {
// Those callers repair state and delete nothing, so a refusal there would wedge a repair.
installHost({ records: [record('s1', WORKTREE)] })
@@ -182,6 +343,262 @@ describe('worktree teardown and structured agent sessions', () => {
).resolves.toMatchObject({ runtimeStopped: 0 })
})
it('leaves a same-id workspace on another execution host alone', async () => {
// A workspace id is `repoId::path` with no host component, so the local, SSH and paired-runtime
// copies of one id are DIFFERENT workspaces. Unfenced, deleting the local one closed a chat
// running on somebody else's machine — a destructive cross-host act, not a spurious refusal.
const host = installHost({
records: [record('s1', WORKTREE, { executionHostId: 'ssh:host-a' })]
})
await expect(killAllProcessesForWorktree(WORKTREE, destructiveDeps())).resolves.toMatchObject({
runtimeStopped: 0
})
expect(host.closed).toEqual([])
})
it('reads an explicit local fence the way the PTY sweeps do', () => {
// This helper reuses the PTY fence's own type, so the two cannot answer `null` differently:
// there it means this machine, and it has to mean this machine here. ABSENT is the one
// deliberate difference — no fence at all for the PTY sweeps, narrowed to local here, because
// a single-host-id comparison cannot express match-all and closing every host's chats is
// destructive. Latent today only because `WorktreeTeardownDeps` cannot yet carry the `null`.
installHost({
records: [record('s1', WORKTREE, { executionHostId: 'ssh:host-a' }), record('s2', WORKTREE)]
})
const local = [{ sessionId: 's2', agent: 'claude' }]
expect(listLiveStructuredSessionsForWorktree(WORKTREE, { resolvedConnectionId: null })).toEqual(
local
)
expect(listLiveStructuredSessionsForWorktree(WORKTREE, {})).toEqual(local)
})
it('closes only the session on the host the removal resolved to', async () => {
const host = installHost({
records: [record('s1', WORKTREE, { executionHostId: 'ssh:host-a' }), record('s2', WORKTREE)]
})
await expect(
killAllProcessesForWorktree(WORKTREE, {
...destructiveDeps(),
resolvedConnectionId: 'host-a'
})
).resolves.toMatchObject({ structuredStopped: 1 })
expect(host.closed).toEqual(['s1'])
})
it('names only the sessions that stayed, and every provider still there', async () => {
installHost({
records: [
record('s1', WORKTREE),
record('s2', WORKTREE, { provider: 'codex' }),
record('s3', WORKTREE)
],
stuck: new Set(['s2', 's3'])
})
const error = await killAllProcessesForWorktree(WORKTREE, destructiveDeps()).catch(
(thrown: Error) => thrown.message
)
expect(error).toContain('still live: 2 agent sessions (claude, codex)')
})
it('names the unconfirmed sessions too, instead of counting only the live ones', async () => {
// The PTY sibling may drop everything outside its live list because a fresh inventory PROVED
// those exited. Nothing proves that here: an `unverifiable` session is unclosed as well, so
// naming only the live subset told the user "1 agent session" while two were about to go.
installHost({
records: [record('s1', WORKTREE), record('s2', WORKTREE, { provider: 'codex' })],
stuck: new Set(['s1']),
unverifiable: new Set(['s2'])
})
const error = await killAllProcessesForWorktree(WORKTREE, destructiveDeps()).catch(
(thrown: Error) => thrown.message
)
expect(error).toContain(
'still live: 1 agent session (claude); could not confirm these closed: 1 agent session (codex)'
)
// The marker still leads, so the toast keeps showing the stronger of the two warnings.
expect(isProvenLiveStructuredSessionRemovalError(error as string)).toBe(true)
})
it('still reports what it closed when a forced removal skips the PTY verdict', async () => {
// A sweep that fails outright short-circuits the per-PTY verdict — but not the structured
// close that already ran, so the count has to survive that return or the removal log claims
// `structured=0` for chats it just ended.
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const runtime = {
stopTerminalsForWorktree: async () => {
throw new Error('the terminal sweep died')
}
} as never
const host = installHost({ records: [record('s1', WORKTREE)] })
const result = await killAllProcessesForWorktree(WORKTREE, {
...destructiveDeps({ allowUnverifiedStop: true }),
runtime
})
expect(host.closed).toEqual(['s1'])
expect(result.structuredStopped).toBe(1)
warn.mockRestore()
})
it('separates a close it could not confirm from one it watched stay attached', async () => {
// `src/shared/worktree/removal.ts` keeps these two apart on purpose: a user waiving "we could
// not confirm" is making a different decision than one discarding a conversation Orca just saw
// running. The toast branches on this marker, so flattening them makes one of the two a lie.
installHost({ records: [record('s1', WORKTREE)], unverifiable: new Set(['s1']) })
const unconfirmed = await killAllProcessesForWorktree(WORKTREE, destructiveDeps()).catch(
(thrown: Error) => thrown.message
)
expect(unconfirmed).toContain('could not confirm these closed: 1 agent session (claude)')
expect(isProvenLiveStructuredSessionRemovalError(unconfirmed as string)).toBe(false)
installHost({ records: [record('s1', WORKTREE)], stuck: new Set(['s1']) })
const live = await killAllProcessesForWorktree(WORKTREE, destructiveDeps()).catch(
(thrown: Error) => thrown.message
)
expect(isProvenLiveStructuredSessionRemovalError(live as string)).toBe(true)
})
it('refuses in agent-session wording when the close outlives the sweep budget', async () => {
// A structured close that runs out of time used to reject with the PTY timeout sentinel, which
// the classifier reads FIRST — so the toast blamed terminals, and the Force Delete meant to
// clear the wedge hit the same rejection again (#11960).
installHost({ records: [record('s1', WORKTREE)], closeGate: new Promise<void>(() => {}) })
const error = await killAllProcessesForWorktree(
WORKTREE,
destructiveDeps({ timeoutMs: 5 })
).catch((thrown: Error) => thrown.message)
expect(error).toContain('could not confirm these closed: 1 agent session (claude)')
expect(isUnstoppedPtyRemovalError(error as string)).toBe(false)
expect(classifyWorktreeForceDeleteReason(error as string, true)).toBe('running-agent-session')
})
it('never wedges Force Delete on a close that will not settle', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
installHost({ records: [record('s1', WORKTREE)], closeGate: new Promise<void>(() => {}) })
await expect(
killAllProcessesForWorktree(
WORKTREE,
destructiveDeps({ allowUnverifiedStop: true, timeoutMs: 5 })
)
).resolves.toMatchObject({ runtimeStopped: 0 })
const message = structuredSessionWarning(warn)
expect(message).toContain('could not confirm these closed: 1 agent session (claude)')
// The pin: a close that ran out of time was never watched stay attached. This warn is the only
// record a forced removal leaves, and the removal.ts split exists precisely so "we could not
// confirm" is never reported as "we saw it running" — including here.
expect(message).not.toContain('still attached')
warn.mockRestore()
})
it('names only the sessions still open when the budget expires mid-close', async () => {
// The close loop is serial, so a deadline can land part-way through it. A fallback assembled
// at the deadline could only name the whole list — so a removal that had already closed the
// first chat still told the user both were still there, which is the exact thing this sweep
// exists to stop doing: never report state nobody observed.
installHost({
records: [record('s1', WORKTREE), record('s2', WORKTREE, { provider: 'codex' })],
closeGates: { s2: new Promise<void>(() => {}) }
})
const error = await killAllProcessesForWorktree(
WORKTREE,
destructiveDeps({ timeoutMs: 40 })
).catch((thrown: Error) => thrown.message)
expect(error).toContain('could not confirm these closed: 1 agent session (codex)')
expect(error).not.toContain('claude')
})
it('counts the closes that landed before the budget expired', async () => {
// The other half of the same fallback: it reported zero closes, so the removal log said
// `structured=0` for a chat it had just ended.
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const slowClose = new Promise<void>((resolve) => {
setTimeout(resolve, 300)
})
installHost({
records: [record('s1', WORKTREE), record('s2', WORKTREE, { provider: 'codex' })],
closeGates: { s2: slowClose }
})
const result = await killAllProcessesForWorktree(
WORKTREE,
destructiveDeps({ allowUnverifiedStop: true, timeoutMs: 40 })
)
expect(result.structuredStopped).toBe(1)
expect(structuredSessionWarning(warn)).toContain(
'could not confirm these closed: 1 agent session (codex)'
)
warn.mockRestore()
})
it('stops issuing new closes once the budget is spent', async () => {
// One slow provider round trip used to starve every session behind it: the outer race had
// already given up on the loop, and it went on issuing closes whose outcome nobody would read.
// The in-flight one is NOT cancelled — nothing here can cancel a provider round trip — so it
// still has to be reported, which is why both sessions are named below.
let releaseFirstClose: () => void = () => {}
const firstClose = new Promise<void>((resolve) => {
releaseFirstClose = resolve
})
const host = installHost({
records: [record('s1', WORKTREE), record('s2', WORKTREE)],
closeGates: { s1: firstClose }
})
const error = await killAllProcessesForWorktree(
WORKTREE,
destructiveDeps({ timeoutMs: 5 })
).catch((thrown: Error) => thrown.message)
expect(error).toContain('could not confirm these closed: 2 agent sessions (claude)')
releaseFirstClose()
await new Promise((resolve) => {
setTimeout(resolve, 25)
})
expect(host.closed).toEqual(['s1'])
})
it('leaves the terminals already stopped when it refuses over a stuck session', async () => {
// Pins a tradeoff that was accepted, not an outcome that is wanted. The PTY sweeps now run
// concurrently with the structured close, so a removal that refuses over a session that will
// not close has ALREADY killed that workspace's terminals — the head-first serial order spared
// them. Serialising it back is worse: it spends the whole shared budget before a single PTY is
// asked, and the alternative — refusing before the PTY sweeps — leaves force-delete removing
// files while PTY handles are open. The PTY gate itself already kills first and refuses only
// on what it could not verify stopped. A later change must not flip this back silently.
let terminalSweeps = 0
const runtime = {
stopTerminalsForWorktree: async () => {
terminalSweeps += 1
return { stopped: 2 }
}
} as never
installHost({ records: [record('s1', WORKTREE)], stuck: new Set(['s1']) })
await expect(
killAllProcessesForWorktree(WORKTREE, { ...destructiveDeps(), runtime })
).rejects.toThrow(/still live: 1 agent session \(claude\)/)
expect(terminalSweeps).toBe(1)
})
it('starts the terminal sweeps while the structured close is still in flight', async () => {
// The close is serial and each one waits on a provider round trip. Awaiting it before the
// sweeps exist spends the shared budget head-first, and the sweeps then report a timeout for
// a stop they never attempted.
let releaseClose: () => void = () => {}
const closeGate = new Promise<void>((resolve) => {
releaseClose = resolve
})
installHost({ records: [record('s1', WORKTREE)], closeGate })
let terminalSweepStarted = false
const runtime = {
stopTerminalsForWorktree: async () => {
terminalSweepStarted = true
return { stopped: 0 }
}
} as never
const removal = killAllProcessesForWorktree(WORKTREE, { ...destructiveDeps(), runtime })
await vi.waitFor(() => {
expect(terminalSweepStarted).toBe(true)
})
releaseClose()
await expect(removal).resolves.toMatchObject({ structuredStopped: 1 })
})
it('does not block removal when no structured host is installed', async () => {
// Not being able to look is not evidence a child is there, and reading the persisted store
// directly would force-install the host as a side effect of a teardown.
@@ -8,15 +8,29 @@
* kept running with its `cwd` gone, the durable record and chat tab survived to republish at the
* next launch pointing at a deleted worktree, and `worker-show` still reported the worker live.
*
* Membership is `location.workspaceId`, which every structured session carries — so this covers a
* plain chat session in the worktree as well as a dispatched worker. Liveness is
* `observeStructuredWorker`, the same `live` / `unverifiable` / `exited` vocabulary the rest of the
* structured surface uses; only a PROVEN live child is worth refusing a removal over.
* Membership is `location.workspaceId` PLUS the host fence below, and every structured session
* carries both — so this covers a plain chat session in the worktree as well as a dispatched
* worker. Liveness is `observeStructuredWorker`, the same `live` / `unverifiable` / `exited`
* vocabulary the rest of the structured surface uses.
*
* `live` here is lease state — a provider child is attached — not work in flight, so it says
* nothing about whether the user would lose anything. It selects what to CLOSE, never what to
* refuse over: a removal refuses only on a close that did not settle, exactly as the PTY sweep
* refuses only on a stop it could not verify.
*/
import {
LOCAL_EXECUTION_HOST_ID,
toRuntimeExecutionHostId,
toSshExecutionHostId,
type ExecutionHostId
} from '../../shared/execution-host'
import { STILL_LIVE_DETAIL_PREFIX } from '../../shared/worktree/removal'
import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
import { observeStructuredWorker } from './structured-worker-authority'
import { closeStructuredAgentSessionChild } from './structured-agent-session-close'
import { retireSettledStructuredWorkerTab } from './structured-agent-session-tab-retirement'
import type { WorktreePtyHostFence } from './worktree-pty-host-fence'
import type { OrcaRuntimeService } from './orca-runtime'
export type LiveStructuredSessionInWorkspace = {
@@ -24,13 +38,52 @@ export type LiveStructuredSessionInWorkspace = {
agent: 'claude' | 'codex'
}
export type UnclosedStructuredSession = LiveStructuredSessionInWorkspace & {
/** Read AFTER the close: `live` is a child watched stay attached, not merely one left unproven. */
status: 'live' | 'unverifiable'
}
export type StructuredWorktreeSweepRuntime = Pick<
OrcaRuntimeService,
'forgetStructuredSessionMail' | 'retireStructuredAgentSessionTabFromSnapshot'
>
/**
* Structured sessions with a proven-live child in this worktree.
* The two fields every teardown caller already resolves to fence its PTY sweeps to one host.
*
* Deliberately the PTY fence's own type rather than a look-alike: these two helpers are written
* against each other, so a widening on one side must not become a silent disagreement on the
* other. `resolvedConnectionId: null` means this machine on both.
*
* They differ in exactly one reading, and only that one: ABSENT. The PTY fence takes it as no
* fence at all and matches every host, which a single-host-id comparison cannot express — and
* closing every host's chats is destructive, not merely noisy. So this side reads absent as local
* too, the narrower half of that pair. Pinned by test, not left to the next reader to rediscover.
*/
export type StructuredSessionHostFence = WorktreePtyHostFence
/**
* The one execution host this teardown may touch.
*
* A workspace id is `repoId::path` with no host component, so the local machine, an SSH host and a
* paired runtime can all publish the SAME id and each names a DIFFERENT workspace (STA-4343). The
* PTY sweeps fence on exactly these two fields; a structured session records its host directly, so
* the comparison is on `location.executionHostId` instead of on a pty-id shape.
*/
export function structuredSessionTeardownHostId(
fence: StructuredSessionHostFence
): ExecutionHostId {
if (fence.resolvedRuntimeEnvironmentId !== undefined) {
return toRuntimeExecutionHostId(fence.resolvedRuntimeEnvironmentId)
}
// Both no-connection readings collapse here on purpose — see the fence type. A caller that
// resolved no host, and one that resolved this machine, each close nothing on anyone else's.
const connectionId = fence.resolvedConnectionId ?? null
return connectionId === null ? LOCAL_EXECUTION_HOST_ID : toSshExecutionHostId(connectionId)
}
/**
* Structured sessions with a proven-live child in this worktree, on the fenced host only.
*
* An uninstalled host answers empty rather than throwing: no host in this generation means no
* provider child was started by this process, and the three PTY sweeps fall through the same way
@@ -38,7 +91,8 @@ export type StructuredWorktreeSweepRuntime = Pick<
* directly — that would force-install the host, which is itself a side effect on a teardown path.
*/
export function listLiveStructuredSessionsForWorktree(
worktreeId: string
worktreeId: string,
fence: StructuredSessionHostFence
): LiveStructuredSessionInWorkspace[] {
const host = getStructuredAgentSessionHost()
if (!host) {
@@ -50,58 +104,191 @@ export function listLiveStructuredSessionsForWorktree(
} catch {
return []
}
const hostId = structuredSessionTeardownHostId(fence)
return records
.filter(
(record) =>
record.location.workspaceId === worktreeId &&
record.location.executionHostId === hostId &&
observeStructuredWorker({ sessionId: record.sessionId }).status === 'live'
)
.map((record) => ({ sessionId: record.sessionId, agent: record.provider }))
}
/**
* Counts and providers, never session ids.
* A count and its providers — never session ids.
*
* A session id is one tab-id hop from the random pane key that gates a worker's mailbox, and this
* string reaches agent-readable CLI output and a desktop toast. The count and the providers are
* what a user deciding whether to force actually needs; the ids identify nothing they can act on.
*/
export function describeLiveStructuredSessions(
sessions: readonly LiveStructuredSessionInWorkspace[]
): string {
function countStructuredSessions(sessions: readonly UnclosedStructuredSession[]): string {
const noun = sessions.length === 1 ? 'agent session' : 'agent sessions'
const providers = [...new Set(sessions.map((session) => session.agent))].sort().join(', ')
return `${sessions.length} running ${noun} (${providers})`
return `${sessions.length} ${noun} (${providers})`
}
/**
* Closes every live structured session in the worktree, and reports what stayed.
* The two post-close verdicts, each with its own count.
*
* Force is the documented escape hatch, so it closes rather than orphaning: a child left running
* against a deleted `cwd` is the exact outcome this whole sweep exists to prevent.
* The split is here for the reason `describeUnstoppedPtys` carries one: "we watched it stay
* attached" and "we could not confirm it went" are different decisions to waive, and the delete
* toast branches on the marker a proven-live session leads with.
*
* Both groups are named, though, which is where this differs from the PTY sibling: there, the
* verdict is a fresh inventory, so anything absent from the live list is PROVEN exited and
* rightly dropped. Here an `unverifiable` session is unclosed too — folding it into the live
* count would overstate what Orca watched, and dropping it said "1 agent session" while three
* were about to be discarded.
*/
export function describeUnclosedStructuredSessions(
sessions: readonly UnclosedStructuredSession[]
): string {
const stillLive = sessions.filter((session) => session.status === 'live')
const unconfirmed = sessions.filter((session) => session.status !== 'live')
if (stillLive.length === 0) {
return `could not confirm these closed: ${countStructuredSessions(unconfirmed)}`
}
const live = `${STILL_LIVE_DETAIL_PREFIX} ${countStructuredSessions(stillLive)}`
return unconfirmed.length === 0
? live
: `${live}; could not confirm these closed: ${countStructuredSessions(unconfirmed)}`
}
/**
* What the close loop has done so far, readable while it is still running.
*
* The loop is serial and every close waits on a provider round trip, so the shared sweep budget can
* expire part-way through it. This is written as it goes rather than returned at the end, because
* the caller's timeout path reads THIS: a fabricated whole-list fallback reported sessions the
* sweep had already closed as unclosed, named them in the refusal the user reads, and logged
* `structured=0` for closes that landed. Saying only what was observed is the point of the sweep.
*/
export type StructuredSweepProgress = {
/** The sessions this sweep closes, in the order the loop reaches them. */
readonly sessions: readonly LiveStructuredSessionInWorkspace[]
/** Sessions no longer attached after their close — the count this sweep reports. */
closed: number
/** Attempted closes that did not settle, each carrying the verdict re-read after the attempt. */
unstopped: UnclosedStructuredSession[]
/** How many of `sessions`, from the front, have an outcome recorded. */
settled: number
}
export function createStructuredSweepProgress(
sessions: readonly LiveStructuredSessionInWorkspace[]
): StructuredSweepProgress {
return { sessions, closed: 0, unstopped: [], settled: 0 }
}
/**
* Everything this sweep did not prove closed.
*
* A session with no recorded outcome — never started, or still in flight — reports `unverifiable`,
* the same verdict as an attempted close that stayed unproven. Chosen, not conflated: the vocabulary is `live` / `unverifiable` / `exited` with no
* synonyms, and "we never asked" and "we asked and could not confirm" are both exactly "not
* observed exited". A fourth bucket would need its own refusal wording and its own toast
* classification for a distinction the user cannot act on any differently — and `live` is the only
* verdict either could be mistaken for, which is the one thing neither is allowed to claim.
*/
export function unclosedStructuredSessions(
progress: StructuredSweepProgress
): UnclosedStructuredSession[] {
return [
...progress.unstopped,
...progress.sessions
.slice(progress.settled)
.map((session) => ({ ...session, status: 'unverifiable' as const }))
]
}
/**
* Closes the structured sessions in `progress`, recording what stayed as it goes.
*
* Runs on the ordinary removal too, not just force: a child left running against a deleted `cwd` is
* the outcome this whole sweep exists to prevent, and closing is how you prevent it. What stayed is
* the only thing worth refusing over.
*
* Takes the list rather than re-deriving it, so the refusal can only ever name a session out of
* the set this sweep was handed — re-enumerating would run every liveness observation twice and
* let it name one this call never touched. Not every one of them is a session a close was
* attempted on: the deadline check below can leave the tail of the list unasked, and
* `unclosedStructuredSessions` reports those as `unverifiable` precisely because nobody looked.
*/
export async function closeStructuredSessionsForWorktree(
worktreeId: string,
runtime?: StructuredWorktreeSweepRuntime
): Promise<{ closed: number; unstopped: LiveStructuredSessionInWorkspace[] }> {
progress: StructuredSweepProgress,
deadline: number,
options: {
runtime?: StructuredWorktreeSweepRuntime
/**
* Whether this removal can still refuse over an unclosed session.
*
* It is the only case where the workspace — and therefore its chat tabs — survives, so it is
* the only case where an unproven close may put a tab back. Force and the folder-workspace
* paths discard the workspace whatever the sweep reports.
*/
mayRefuse?: boolean
} = {}
): Promise<void> {
const { runtime, mayRefuse } = options
// No `afterClose` for a dispatched worker: `host.close` drops the holds, so nothing keeps a
// provider child un-evictable, but the dispatch's redrive subscription and registry entry do
// survive until it settles by another verb. That is a bounded leak, not a hazard — and passing
// one here would mean resolving a dispatch id per session on a teardown path that must stay
// inside the sweep deadline.
const sessions = listLiveStructuredSessionsForWorktree(worktreeId)
const unstopped: LiveStructuredSessionInWorkspace[] = []
let closed = 0
for (const session of sessions) {
const outcome = await closeStructuredAgentSessionChild(
session.sessionId,
runtime ? { runtime } : {}
)
if (outcome.stopped) {
closed += 1
} else {
unstopped.push(session)
for (const session of progress.sessions) {
// Stops ISSUING new closes once the budget is spent; an in-flight one is left to finish, since
// nothing here can cancel a provider round trip. Without this, one slow round trip starved
// every session behind it: the caller's race had already given up, and the loop went on
// closing sessions whose outcome nobody would read.
if (Date.now() >= deadline) {
return
}
const outcome = await closeStructuredAgentSessionChild(session.sessionId, {
...(runtime ? { runtime } : {}),
restoreTabOnUnprovenClose: mayRefuse === true
})
if (outcome.stopped) {
progress.closed += 1
} else {
// Re-observed rather than reusing the close's own reason string: what the user is asked to
// waive is the state AFTER the attempt, and a close that threw never reached an observation.
const status = observeStructuredWorker({ sessionId: session.sessionId }).status
if (status === 'exited') {
// The re-read can PROVE the exit a failed close could not — it threw past its own
// observation, or the record's death evidence landed after it read. Refusing on a child
// that is demonstrably gone is the defect this sweep exists to remove, so take the proof
// and run the retirement `closeStructuredAgentSessionChild` skipped when it gave up.
//
// Including the hide it UNDID: its rollback ran against an observation taken one store
// write before this one, so a child that died in between left the tab republished for a
// session this sweep is about to count closed. Taking the proof has to take that back.
await dropDurableChatTabReference(session.sessionId)
retireSettledStructuredWorkerTab(session.sessionId, runtime)
progress.closed += 1
} else {
progress.unstopped.push({ ...session, status })
}
}
// Advanced only once an outcome is recorded, so a close still in flight when the deadline
// lands stays reported as unclosed instead of falling out of both counts.
progress.settled += 1
}
}
/**
* Drops a settled session's durable chat-tab reference, and cannot fail the settlement.
*
* The close's own hide is the ordinary path; this is only for the session whose exit this sweep
* proved after that close had already rolled the hide back.
*/
async function dropDurableChatTabReference(sessionId: string): Promise<void> {
try {
await getStructuredAgentSessionHost()?.setSessionTabVisibility?.(sessionId, false)
} catch (error) {
console.warn(
`[worktree-teardown] could not drop the chat tab reference for ${sessionId}`,
error
)
}
return { closed, unstopped }
}
@@ -3,7 +3,10 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
import {
createTranscriptPane as createPane,
TRANSCRIPT_PANE_PTY_ID as PTY_ID
} from './agent-transcript-pane-test-harness'
import { assertTerminalAgentSendable } from './rpc/terminal-agent-send-guard'
vi.mock('electron', () => ({
@@ -13,12 +16,11 @@ vi.mock('electron', () => ({
app: { getPath: vi.fn(() => '/tmp') }
}))
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const TAB_ID = 'tab-1'
const WORKTREE_ID = 'wt-1'
const PTY_ID = 'pty-1'
// Captured verbatim from cursor-agent 2026.08.11-e8db854 driven through Orca.
// cursor-agent 2026.08.11-e8db854's screens, but NOT raw PTY output: these files contain no
// escape bytes and no carriage returns, so they came through a terminal's renderer and a
// clipboard. They evidence wording, ordering and glyphs — which is all the rules below key on —
// and evidence nothing about the caret, cursor moves, repaints or the alternate screen buffer.
// Record new fixtures with config/scripts/capture-agent-pty-transcript.mjs, which keeps the bytes.
function fixture(name: string): string {
return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8')
}
@@ -39,73 +41,6 @@ function agentStatusOsc(state: string): string {
return `]9999;${JSON.stringify({ state, prompt: 'ship it', agentType: 'claude' })}`
}
async function createPane(options: {
paneTitle: string
foregroundProcess: string | null
data: string
/** Set for a pane whose PTY lives on an SSH host or WSL distro rather than locally. */
connectionId?: string
/** Simulates a PTY controller whose foreground probe never settles. */
foregroundProbeHangs?: boolean
onForegroundProbe?: () => void
}): Promise<{ runtime: OrcaRuntimeService; handle: string }> {
const runtime = new OrcaRuntimeService(null)
const internals = runtime as unknown as {
resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise<unknown>
}
vi.spyOn(internals, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({
id: WORKTREE_ID,
path: '/repo/app',
connectionId: options.connectionId ?? null,
repo: null,
folderWorkspace: null
})
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: PTY_ID, incarnationId: 'inc-1' }),
write: () => true,
kill: () => true,
getForegroundProcess: (): Promise<string | null> => {
options.onForegroundProbe?.()
return options.foregroundProbeHangs === true
? new Promise<string | null>(() => {})
: Promise.resolve(options.foregroundProcess)
}
})
const terminal = await runtime.createTerminal(`id:${WORKTREE_ID}`, {
tabId: TAB_ID,
leafId: LEAF_ID,
title: 'Terminal'
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
title: 'Terminal',
activeLeafId: LEAF_ID,
layout: null
}
],
leaves: [
{
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
leafId: LEAF_ID,
paneRuntimeId: 1,
ptyId: PTY_ID,
paneTitle: options.paneTitle
}
]
})
// Why the guard: a restore seed is only applied to a never-written record, so the restore
// cases must not write an empty chunk first.
if (options.data.length > 0) {
runtime.onPtyData(PTY_ID, options.data, Date.now())
}
return { runtime, handle: terminal.handle }
}
// cursor-agent renders a braille spinner in its OSC title while it works, and Orca reads
// that as `working`; the title is identical whether it is running a command or waiting.
const CURSOR_TITLE = '⠇ Cursor Agent'
@@ -2,7 +2,7 @@ import type { IPtyProvider } from '../providers/types'
import type { OrcaRuntimeService } from './orca-runtime'
import {
UNSTOPPED_PTY_DETAIL_SEPARATOR,
UNSTOPPED_PTY_LIVE_DETAIL_PREFIX,
STILL_LIVE_DETAIL_PREFIX,
UNSTOPPED_PTY_REMOVAL_PREFIX
} from '../../shared/worktree/removal'
import {
@@ -105,7 +105,7 @@ export function describeUnstoppedPtys(
): string {
const detail =
verdict.status === 'live'
? `${UNSTOPPED_PTY_LIVE_DETAIL_PREFIX} ${verdict.ptyIds.join(', ')}`
? `${STILL_LIVE_DETAIL_PREFIX} ${verdict.ptyIds.join(', ')}`
: `could not verify these exited: ${failedPtyIds.join(', ')} (${verdict.reason})`
return `${UNSTOPPED_PTY_REMOVAL_PREFIX} ${worktreeId}${UNSTOPPED_PTY_DETAIL_SEPARATOR}${detail}`
}
@@ -1,8 +1,14 @@
export type WorktreePtyHostFence = {
/** `null` is this machine; ABSENT is no fence at all, so every host matches. */
resolvedConnectionId?: string | null
resolvedRuntimeEnvironmentId?: string
}
/**
* Also fences the structured sweep, through `structuredSessionTeardownHostId`, which reuses this
* exact type so the two cannot drift. That helper narrows ABSENT to local — the one deliberate
* difference, documented where it is made.
*/
export function worktreePtyBelongsToHost(
ptyId: string,
connectionId: string | null | undefined,
+82 -41
View File
@@ -15,10 +15,16 @@ import {
} from './worktree-pty-surface-sweeps'
import {
closeStructuredSessionsForWorktree,
describeLiveStructuredSessions,
listLiveStructuredSessionsForWorktree
createStructuredSweepProgress,
describeUnclosedStructuredSessions,
listLiveStructuredSessionsForWorktree,
unclosedStructuredSessions
} from './structured-session-worktree-teardown'
import { createWorktreeSweepTracker, settleSweepsForForcedRemoval } from './forced-sweep-settlement'
import {
createWorktreeSweepTracker,
settleSweepsForForcedRemoval,
type WorktreeSweepTracker
} from './forced-sweep-settlement'
import {
describeError,
describeFailedPtySweep,
@@ -57,7 +63,7 @@ export type WorktreeTeardownResult = {
runtimeStopped: number
providerStopped: number
registryStopped: number
/** Structured agent sessions closed by the force path; absent when none were found. */
/** Structured agent sessions this teardown closed; absent when it closed none. */
structuredStopped?: number
}
@@ -99,12 +105,18 @@ export async function killAllProcessesForWorktree(
const deadlineError = new Error(
`${WORKTREE_TEARDOWN_TIMEOUT_PREFIX} ${worktreeId}. ${WORKTREE_TEARDOWN_FORCE_HINT}`
)
// FIRST, and before a single PTY sweep starts: a structured agent session is registered on none
// of the three surfaces below, so all three answered zero and removal deleted the checkout out
// from under a running provider child. Refusing costs nothing when there are none, and the check
// is synchronous, so a destructive removal fails fast instead of after the whole sweep budget.
const structuredStopped = await sweepStructuredSessions(worktreeId, deps, deadline, deadlineError)
const sweeps = createWorktreeSweepTracker()
// ISSUED first, before a single PTY is touched: a structured agent session is registered on none
// of the three surfaces below, so all three answered zero and removal deleted the checkout out
// from under a running provider child. Asking the agent plane ahead of the terminal plane also
// keeps an intentional stop from reading as a failed process exit.
//
// Not AWAITED first, though. Its close is serial and each one waits on a provider round trip, so
// awaiting here would spend the shared budget before a single PTY was asked — and the sweeps
// would then report a timeout for a stop they never attempted. It is joined below, ahead of the
// PTY verdict, so a structured refusal still outranks one.
const structuredSweep = sweepStructuredSessions(worktreeId, deps, deadline, sweeps)
void structuredSweep.catch(() => undefined)
const stopAttempts = new Map<string, Promise<boolean>>()
const stopPty = (
ptyId: string,
@@ -196,6 +208,7 @@ export async function killAllProcessesForWorktree(
for (const sweep of [runtimeSweep, providerSweep, registrySweep]) {
void sweep.catch(() => undefined)
}
const structuredStopped = await structuredSweep
let runtimeResult: { stopped: number }
let providerStopped: number
let registryStopped: number
@@ -207,7 +220,10 @@ export async function killAllProcessesForWorktree(
deadlineError
)
if (forced.incomplete) {
return forced.stopped
// Carries the structured count out too: this early return skips the PTY verdict, not the
// sweep that already closed a user's chats, and dropping it makes the log say `structured=0`
// for a removal that closed some.
return { ...forced.stopped, ...(structuredStopped > 0 ? { structuredStopped } : {}) }
}
runtimeResult = { stopped: forced.stopped.runtimeStopped }
providerStopped = forced.stopped.providerStopped
@@ -277,59 +293,84 @@ export async function killAllProcessesForWorktree(
}
/**
* The fourth sweep: structured agent sessions bound to this worktree.
* The fourth sweep: structured agent sessions bound to this worktree, on this host.
*
* Refuses rather than auto-closing on the ordinary destructive path. `worktree rm` is the verb
* that deletes a user's work, and a running agent session is exactly the thing they would want to
* be told about before it goes — the same bargain the unstopped-PTY gate already strikes, using
* the same `--force` escape hatch. Force closes them properly instead of orphaning a child against
* a `cwd` that is about to disappear.
* Stops first and refuses only on unproven stops, which is the bargain the unstopped-PTY gate
* actually strikes: that gate kills every PTY — a terminal running an agent included — and refuses
* only for the ones whose exit it could not then verify. Refusing merely because a session is
* attached made an idle chat, which the user is done with, harder to delete than a terminal running
* the same agent. Attachment is lease state, not work in flight, so it was never the right proxy.
*
* Two callers participate, for different reasons. A proof-requiring removal (`requirePhysicalStop`)
* refuses, then closes under force. A folder-workspace removal (`closeStructuredSessions`) closes
* best-effort without refusing: it shares its root so no checkout vanishes under the child, and one
* of those paths is a never-throw forget that a refusal would wedge. Reconciliation sweeps set
* neither — they repair state, delete nothing, and must never close a session.
* Two callers participate. A proof-requiring removal (`requirePhysicalStop`) refuses when a close
* does not settle, so nothing deletes a checkout out from under a child that is still there. A
* folder-workspace removal (`closeStructuredSessions`) never refuses: it shares its root so no
* checkout vanishes under the child, and every one of those call sites discards a rejection, so a
* refusal there would be words nobody reads. Reconciliation sweeps set neither — they repair state,
* delete nothing, and must never close a session.
*/
async function sweepStructuredSessions(
worktreeId: string,
deps: WorktreeTeardownDeps,
deadline: number,
deadlineError: Error
sweeps: WorktreeSweepTracker
): Promise<number> {
if (!deps.requirePhysicalStop && !deps.closeStructuredSessions) {
return 0
}
const live = listLiveStructuredSessionsForWorktree(worktreeId)
// `deps` carries the same two host fields the PTY sweeps fence on, and a `repoId::path` id names
// a different workspace on every host — so an unfenced list would close a live chat belonging to
// an SSH or paired-runtime copy of the id being removed here.
const live = listLiveStructuredSessionsForWorktree(worktreeId, deps)
if (live.length === 0) {
return 0
}
// Raced against the same sweep budget every PTY surface is bounded by, because `host.close`
// awaits a provider round trip whose own eviction steps are each bounded well past this budget.
//
// Deliberately NOT fail-closed, unlike the PTY sweeps: their timeout sentinel carries the PTY
// timeout prefix, which the desktop classifier reads as a TERMINAL failure — so a wedged session
// close would refuse in terminal wording, and refuse identically again under the Force Delete
// that is meant to clear it (#11960). A close that ran out of time is a session this removal
// could not confirm closed, which is exactly what the branch below already words. Tracked so a
// forced removal still waits out the abandoned-sweep grace before it deletes files.
//
// The verdict is read off `progress`, which the serial loop fills as it goes, rather than off
// this call's result: the deadline can land mid-loop, and a fallback assembled here could only
// guess — it named every session, including the ones already closed, and reported zero closes.
const progress = createStructuredSweepProgress(live)
await settleBeforeDeadline(
sweeps.track(() =>
closeStructuredSessionsForWorktree(progress, deadline, {
...(deps.runtime ? { runtime: deps.runtime } : {}),
// The only shape of removal that can leave this workspace — and its chat tabs — in place.
mayRefuse: Boolean(deps.requirePhysicalStop) && !deps.allowUnverifiedStop
})
),
undefined,
deadline
)
const closed = progress.closed
const unstopped = unclosedStructuredSessions(progress)
if (unstopped.length === 0) {
return closed
}
// Only a proof-requiring removal may refuse. A folder-workspace removal shares its root, so no
// checkout disappears under the child — the harm is a session left pointing at a workspace Orca
// has forgotten — and one of those paths is a never-throw forget, which a refusal would wedge.
// has forgotten — and every one of those callers discards a rejection anyway.
if (deps.requirePhysicalStop && !deps.allowUnverifiedStop) {
// The prefix is what the desktop classifier matches on; without it the toast shows raw CLI
// wording and hides the Force Delete button — the #11960 dead end this file already documents.
throw new Error(
`${RUNNING_AGENT_SESSION_REMOVAL_PREFIX} ${worktreeId}${UNSTOPPED_PTY_DETAIL_SEPARATOR}${describeLiveStructuredSessions(live)}. ${WORKTREE_TEARDOWN_FORCE_HINT}`
`${RUNNING_AGENT_SESSION_REMOVAL_PREFIX} ${worktreeId}${UNSTOPPED_PTY_DETAIL_SEPARATOR}${describeUnclosedStructuredSessions(unstopped)}. ${WORKTREE_TEARDOWN_FORCE_HINT}`
)
}
// Raced against the same sweep budget every PTY surface is bounded by: `host.close` awaits a
// provider round trip, and a wedged one would otherwise hang `worktree rm --force` forever with
// no timeout error at all. On expiry the force path reports the timeout exactly as the PTY
// sweeps do rather than proceeding as if the sessions had closed.
const { closed, unstopped } = await settleBeforeDeadline(
() => closeStructuredSessionsForWorktree(worktreeId, deps.runtime),
{ closed: 0, unstopped: live },
deadline,
deadlineError
// Force is the documented escape hatch, so removal continues — but say so, because the child
// outliving its `cwd` is the failure this sweep exists to make visible. Carries the verdict
// verbatim, like the unstopped-PTY warn above: this line is the only record a forced removal
// leaves, and appending "still attached" asserted the live verdict over sessions the sweep had
// just said it could not confirm either way.
console.warn(
`[worktree-teardown] forcing removal of ${worktreeId}${UNSTOPPED_PTY_DETAIL_SEPARATOR}${describeUnclosedStructuredSessions(unstopped)}`
)
if (unstopped.length > 0) {
// Force is the documented escape hatch, so removal continues — but say so, because the child
// outliving its `cwd` is the failure this sweep exists to make visible.
console.warn(
`[worktree-teardown] forcing removal of ${worktreeId} with ${describeLiveStructuredSessions(unstopped)} still attached`
)
}
return closed
}
+3
View File
@@ -1,3 +1,4 @@
import type { RuntimeHostStatusSnapshot } from '../../shared/runtime-host-status'
import type {
RuntimeBrowserDriverState,
RuntimeRendererSyncWindowGraph,
@@ -77,6 +78,8 @@ export type RuntimeApi = {
) => () => void
}
runtimeEnvironments: {
getStatusSnapshots: () => Promise<RuntimeHostStatusSnapshot[]>
onStatusChanged: (callback: (snapshot: RuntimeHostStatusSnapshot) => void) => () => void
list: () => Promise<PublicKnownRuntimeEnvironment[]>
addFromPairingCode: (args: {
name: string
@@ -1,4 +1,8 @@
import { ipcRenderer } from 'electron'
import {
RUNTIME_HOST_STATUS_CHANNEL,
type RuntimeHostStatusSnapshot
} from '../../shared/runtime-host-status'
import type { VerifyAndAddRuntimeEnvironmentResult } from '../../shared/remote-pairing-verification'
import type { RuntimeStatus } from '../../shared/runtime-types'
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
@@ -12,6 +16,16 @@ import {
import type { PreloadApi } from '../api-types'
export const runtimeEnvironmentsApi = {
getStatusSnapshots: (): Promise<RuntimeHostStatusSnapshot[]> =>
ipcRenderer.invoke('runtimeEnvironments:getStatusSnapshots'),
onStatusChanged: (callback: (snapshot: RuntimeHostStatusSnapshot) => void): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
snapshot: RuntimeHostStatusSnapshot
): void => callback(snapshot)
ipcRenderer.on(RUNTIME_HOST_STATUS_CHANNEL, listener)
return () => ipcRenderer.removeListener(RUNTIME_HOST_STATUS_CHANNEL, listener)
},
list: (): Promise<PublicKnownRuntimeEnvironment[]> =>
ipcRenderer.invoke('runtimeEnvironments:list'),
addFromPairingCode: (args: {
@@ -242,17 +242,11 @@ export default function NewWorkspaceComposerCard(
selector: action.environmentId,
timeoutMs: 15_000
})
const runtimeStatus = unwrapRuntimeRpcResult<RuntimeStatus>(response)
useAppStore.getState().setRuntimeEnvironmentStatus(action.environmentId, {
status: runtimeStatus,
checkedAt: Date.now()
})
unwrapRuntimeRpcResult<RuntimeStatus>(response)
await useAppStore.getState().readRuntimeHostStatusSnapshots()
} catch (error) {
if (action.kind === 'runtime') {
useAppStore.getState().setRuntimeEnvironmentStatus(action.environmentId, {
status: null,
checkedAt: Date.now()
})
await useAppStore.getState().readRuntimeHostStatusSnapshots()
}
toast.error(
error instanceof Error
@@ -83,8 +83,7 @@ describe('getPaletteHostBadge', () => {
repos: [{ executionHostId: 'runtime:env-1' }],
sshTargetLabels: new Map(),
settings: { activeRuntimeEnvironmentId: 'env-2' },
// A live status makes the runtime 'available'; without it the host reads
// 'disconnected' and the badge is suppressed (covered below).
// Only verified availability enables unfiltered host badges.
runtimeStatusByEnvironmentId: new Map([
[
'env-1',
@@ -145,3 +144,19 @@ describe('getPaletteHostBadge', () => {
expect(getPaletteHostBadge(null, hosts)).toBeNull()
})
})
it.each(['connecting', 'blocked', 'disconnected', 'error'] as const)(
'does not infer reachability from %s health, but preserves explicit filter labels',
(health) => {
const hosts = buildSidebarHostOptions({
repos: [{ executionHostId: 'runtime:env-1' }],
sshTargetLabels: new Map(),
settings: { activeRuntimeEnvironmentId: null }
}).map((host) => (host.kind === 'runtime' ? { ...host, health } : host))
expect(getPaletteHostBadge({ connectionId: null }, hosts)).toBeNull()
expect(getPaletteHostBadge({ executionHostId: 'runtime:env-1' }, hosts, true)).toEqual({
hostId: 'runtime:env-1',
label: 'env-1'
})
}
)
@@ -17,7 +17,7 @@ export type PaletteHostBadge = {
// unlike the sidebar gate, which lists disconnected hosts so users can connect.
function hasActiveRemoteHost(hostOptions: readonly SidebarHostOption[]): boolean {
return hostOptions.some(
(host) => host.id !== LOCAL_EXECUTION_HOST_ID && host.health !== 'disconnected'
(host) => host.id !== LOCAL_EXECUTION_HOST_ID && host.health === 'available'
)
}
@@ -95,221 +95,6 @@ describe('NativeChatMessageList assistant messages', () => {
expect(document.querySelector('.text-destructive')).toBeNull()
})
it('keeps a reduced-motion-safe spinner activity line at the tail of a no-tool Codex turn', () => {
render(
<NativeChatMessageList
session={{
...session,
status: 'working',
messages: [
{
id: 'user-prose',
role: 'user',
blocks: [{ type: 'text', text: 'Write a long answer' }],
timestamp: 1,
source: 'transcript'
},
{
id: 'assistant-prose',
role: 'assistant',
blocks: [{ type: 'text', text: 'The answer is still streaming.' }],
timestamp: 2,
source: 'transcript'
}
]
}}
isWorking
expandSignal={false}
fontScale={1}
/>
)
const activity = screen.getByText('Working…')
const row = activity.closest('[data-native-chat-turn-activity]')
const spinner = row?.querySelector('svg')
expect(activity).not.toHaveClass('animate-pulse', 'animate-spin')
expect(spinner).toHaveClass('size-4', 'animate-spin', 'motion-reduce:animate-none')
expect(row).toHaveAttribute('aria-live', 'polite')
expect(screen.getByText('The answer is still streaming.').compareDocumentPosition(row!)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING
)
})
it('keeps the broad fallback distinct from the running tool row', () => {
render(
<NativeChatMessageList
session={{
...session,
status: 'working',
messages: [
{
id: 'assistant-running-tool',
role: 'assistant',
blocks: [
{
type: 'tool-call',
name: 'shell',
input: { command: 'pnpm test' },
state: 'running'
}
],
timestamp: 1,
source: 'transcript'
}
]
}}
isWorking
expandSignal={false}
fontScale={1}
/>
)
const toolLabel = screen.getByText('Running pnpm test')
expect(toolLabel).toHaveClass('animate-pulse')
expect(screen.getAllByText('Running pnpm test')).toHaveLength(1)
const activity = screen.getByText('Working…')
expect(activity.textContent).not.toBe(toolLabel.textContent)
expect(activity).not.toHaveTextContent('shell')
expect(activity).not.toHaveTextContent('pnpm test')
const spinner = activity.closest('[data-native-chat-turn-activity]')?.querySelector('svg')
expect(activity).not.toHaveClass('animate-pulse', 'animate-spin')
expect(spinner).toHaveClass('animate-spin', 'motion-reduce:animate-none')
})
it('uses the broad fallback after a tool settles', () => {
render(
<NativeChatMessageList
session={{
...session,
status: 'working',
messages: [
{
id: 'assistant-completed-tool',
role: 'assistant',
blocks: [
{
type: 'tool-call',
name: 'shell',
input: { command: 'pnpm test' },
state: 'completed'
},
{ type: 'tool-result', output: 'passed' }
],
timestamp: 1,
source: 'transcript'
}
]
}}
isWorking
expandSignal={false}
fontScale={1}
/>
)
const settledTool = screen.getByText('shell')
const activity = screen.getByText('Working…')
expect(activity.textContent).not.toBe(settledTool.textContent)
expect(activity).not.toHaveTextContent('shell')
expect(activity).not.toHaveTextContent('pnpm test')
expect(activity).not.toHaveClass('animate-pulse', 'animate-spin')
expect(activity.closest('[data-native-chat-turn-activity]')?.querySelector('svg')).toHaveClass(
'animate-spin'
)
})
it('keeps a completed tool row static while the turn tail spins, then removes the tail', () => {
const workingSession: NativeChatLiveSession = {
...session,
status: 'working',
messages: [
{
id: 'assistant-settled-tool',
role: 'assistant',
blocks: [
{
type: 'tool-call',
name: 'shell',
input: { command: 'pnpm test' },
state: 'completed'
},
{ type: 'tool-result', output: 'passed' }
],
timestamp: 1,
source: 'transcript'
}
]
}
const { container, rerender } = render(
<NativeChatMessageList
session={workingSession}
isWorking
turnActivity={{ kind: 'description', text: 'Preparing the answer' }}
expandSignal={false}
fontScale={1}
/>
)
const settledTool = screen.getByText('shell')
expect(settledTool).toHaveTextContent('shell pnpm test')
expect(settledTool.closest('button')?.querySelector('.animate-pulse')).toBeNull()
expect(settledTool.closest('button')?.querySelector('.lucide-check')).toBeInTheDocument()
const activity = screen.getByText('Preparing the answer')
expect(activity).not.toHaveClass('animate-pulse', 'animate-spin')
expect(activity.closest('[data-native-chat-turn-activity]')?.querySelector('svg')).toHaveClass(
'animate-spin'
)
rerender(
<NativeChatMessageList
session={{ ...workingSession, status: 'ready' }}
isWorking={false}
turnActivity={{ kind: 'description', text: 'Preparing the answer' }}
expandSignal={false}
fontScale={1}
/>
)
expect(container.querySelector('[data-native-chat-turn-activity]')).toBeNull()
expect(container.querySelector('.animate-pulse')).toBeNull()
expect(container.querySelector('.animate-spin')).toBeNull()
})
it('keeps bridge chats on the legacy activity chrome', () => {
render(
<NativeChatMessageList
session={{
...session,
status: 'working',
messages: [
{
id: 'bridge-tool',
role: 'assistant',
blocks: [
{
type: 'tool-call',
name: 'shell',
input: { command: 'sleep 5' },
state: 'running'
}
],
timestamp: 1,
source: 'transcript'
}
]
}}
isWorking
expandSignal={false}
fontScale={1}
showTurnStatus={false}
/>
)
expect(screen.queryByText('Thinking')).toBeNull()
expect(screen.queryByRole('button', { name: 'Toggle turn details' })).toBeNull()
expect(screen.queryByText('Running sleep 5')).toBeNull()
expect(document.querySelectorAll('.animate-bounce')).toHaveLength(3)
})
it('keeps the current tool live when a stale completed lifecycle meets active hook state', () => {
render(
<NativeChatMessageList
@@ -342,235 +127,6 @@ describe('NativeChatMessageList assistant messages', () => {
expect(screen.getByText('Running sleep 5')).toBeInTheDocument()
})
it('shows a stable thinking status directly below the user message', () => {
const { container } = render(
<NativeChatMessageList
session={{
...session,
status: 'working',
messages: [
{
id: 'user-thinking',
role: 'user',
blocks: [{ type: 'text', text: 'Start the task' }],
timestamp: Date.now(),
source: 'transcript'
}
]
}}
isWorking
expandSignal={false}
fontScale={1}
/>
)
const user = screen.getByText('Start the task')
const thinking = screen.getByText('Thinking')
expect(user.compareDocumentPosition(thinking)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
expect(thinking.parentElement).not.toHaveClass('border-b')
expect(thinking.parentElement).toHaveClass('text-sm')
expect(container.querySelector('.animate-bounce')).toBeNull()
expect(thinking).toHaveClass('animate-pulse')
expect(container.querySelectorAll('.size-1.5.animate-pulse')).toHaveLength(0)
})
it('places the thinking status directly after the latest user message', () => {
render(
<NativeChatMessageList
session={{
...session,
status: 'working',
messages: [
{
id: 'user-1',
role: 'user',
blocks: [{ type: 'text', text: 'Run the checks' }],
timestamp: 1,
source: 'transcript'
},
{
id: 'assistant-1',
role: 'assistant',
blocks: [{ type: 'text', text: 'I am checking now.' }],
timestamp: 2,
source: 'transcript'
}
]
}}
isWorking
expandSignal={false}
fontScale={1}
/>
)
const user = screen.getByText('Run the checks')
const status = screen.getByText('Working for 0s')
const assistant = screen.getByText('I am checking now.')
expect(user.compareDocumentPosition(status)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
expect(status.compareDocumentPosition(assistant)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
expect(status.parentElement).toHaveClass('border-b')
})
it('shows elapsed working time once tool activity starts', () => {
render(
<NativeChatMessageList
session={{
...session,
status: 'working',
messages: [
{
id: 'tool-1',
role: 'assistant',
blocks: [
{
type: 'tool-call',
name: 'shell',
input: { command: 'sleep 5' },
state: 'running'
}
],
timestamp: 1,
source: 'transcript'
}
]
}}
isWorking
workingStartedAt={Date.now() - 3000}
expandSignal={false}
fontScale={1}
/>
)
expect(screen.getByText('Working for 3s')).toBeInTheDocument()
})
it('keeps the completed duration below the user message', () => {
const startedAt = Date.now() - 3000
const turnSession: NativeChatLiveSession = {
...session,
status: 'working',
messages: [
{
id: 'user-complete',
role: 'user',
blocks: [{ type: 'text', text: 'Complete this task' }],
timestamp: startedAt,
source: 'transcript'
},
{
id: 'assistant-complete',
role: 'assistant',
blocks: [{ type: 'text', text: 'Task complete.' }],
timestamp: Date.now(),
source: 'transcript'
}
]
}
const { rerender } = render(
<NativeChatMessageList
session={turnSession}
isWorking
workingStartedAt={startedAt}
expandSignal={false}
fontScale={1}
/>
)
rerender(
<NativeChatMessageList
session={{ ...turnSession, status: 'ready' }}
isWorking={false}
workingStartedAt={null}
expandSignal={false}
fontScale={1}
/>
)
const user = screen.getByText('Complete this task')
const status = screen.getByText('Worked for 3s')
const assistant = screen.getByText('Task complete.')
expect(user.compareDocumentPosition(status)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
expect(status.compareDocumentPosition(assistant)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
rerender(
<NativeChatMessageList
session={{
...turnSession,
status: 'working',
messages: [
...turnSession.messages,
{
id: 'user-next',
role: 'user',
blocks: [{ type: 'text', text: 'Start another task' }],
timestamp: Date.now(),
source: 'transcript'
}
]
}}
isWorking
workingStartedAt={Date.now()}
expandSignal={false}
fontScale={1}
/>
)
expect(screen.getByText('Worked for 3s')).toBeInTheDocument()
expect(screen.getByText('Thinking')).toBeInTheDocument()
})
it("uses the completed caret to expand that turn's tool details", () => {
const startedAt = Date.now() - 3000
render(
<NativeChatMessageList
session={{
...session,
status: 'ready',
messages: [
{
id: 'user-details',
role: 'user',
blocks: [{ type: 'text', text: 'Inspect the repo' }],
timestamp: startedAt,
source: 'transcript'
},
{
id: 'assistant-details',
role: 'assistant',
blocks: [
{
type: 'tool-call',
name: 'shell',
input: { command: 'pwd' },
state: 'completed'
},
{ type: 'tool-result', output: '/repo' }
],
timestamp: Date.now(),
source: 'transcript'
}
]
}}
isWorking={false}
workingStartedAt={startedAt}
expandSignal={false}
fontScale={1}
/>
)
const status = screen.getByRole('button', { name: 'Toggle turn details' })
expect(status).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByRole('button', { name: /1× shell/ })).toBeNull()
fireEvent.click(status)
expect(status).toHaveAttribute('aria-expanded', 'true')
const tool = screen.getByRole('button', { name: /1× shell/ })
expect(tool).toHaveAttribute('aria-expanded', 'true')
expect(screen.getAllByRole('button', { name: /shell pwd/ })[1]).toHaveAttribute(
'aria-expanded',
'false'
)
})
})
// List-level, because every defect this feature has shipped so far lived in the
@@ -9,11 +9,10 @@ import { nativeChatTaskListPredecessors } from './native-chat-task-list-history'
import { NativeChatTaskList } from './NativeChatTaskList'
import { projectNativeChatTaskListFrames } from './native-chat-task-list-frames'
import { shouldShowNativeChatTypingIndicator } from './native-chat-typing-indicator'
import { NativeChatWorkingStatus } from './NativeChatWorkingStatus'
import { useNativeChatTurnStatus } from './use-native-chat-turn-status'
import { NativeChatTypingIndicatorRow } from './NativeChatTypingIndicatorRow'
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
import type { NativeChatTurnActivity } from './native-chat-turn-activity'
import type { NativeChatTurnActivity } from '../../../../shared/native-chat-turn-activity'
import { NativeChatTurnActivityLine } from './NativeChatTurnActivityLine'
import {
NativeChatDisclosureContext,
@@ -29,6 +28,7 @@ import { useNativeChatTranscriptWindow } from './use-native-chat-transcript-wind
import { useNativeChatTranscriptScroll } from './use-native-chat-transcript-scroll'
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
import { isStructuredAgentSessionThinking } from '../../../../shared/structured-agent-session-live-turn'
import type { NativeChatSettledTurns } from '../../../../shared/native-chat-turn-status'
import {
nativeChatTurnDiffs,
@@ -150,12 +150,19 @@ export function NativeChatMessageList({
: new Map<string, NativeChatTurnDiff>(),
[journalItems, messages, turnKeys]
)
// "Thinking" is real reasoning content at the tail of the turn, not the absence
// of output — the latter reports thinking while the request is merely in flight.
const thinking = useMemo(
() => (journalItems ? isStructuredAgentSessionThinking(journalItems) : false),
[journalItems]
)
const turnStatuses = useNativeChatTurnStatus({
messages,
latestUserIndex,
isWorking: showTurnStatus && isWorking,
workingStartedAt: showTurnStatus ? workingStartedAt : null,
settledTurns: showTurnStatus ? settledTurns : null
settledTurns: showTurnStatus ? settledTurns : null,
thinking
})
const lifecycleWorking = session.transcriptLifecycle?.state === 'working'
const slots = useMemo(
@@ -169,7 +176,6 @@ export function NativeChatMessageList({
turnStatuses,
turnDiffs,
showTurnStatus,
showTypingIndicator,
isWorking,
lifecycleWorking
}),
@@ -181,7 +187,6 @@ export function NativeChatMessageList({
messages,
receipts,
showTurnStatus,
showTypingIndicator,
turnDiffs,
turnKeys,
turnStatuses
@@ -280,18 +285,11 @@ export function NativeChatMessageList({
context={rowContext}
window={transcriptWindow}
/>
{showTurnStatus &&
latestUserIndex === -1 &&
turnStatuses.active &&
showTypingIndicator ? (
<NativeChatWorkingStatus
startedAt={turnStatuses.active.startedAt}
thinking={turnStatuses.active.thinking}
workedSeconds={turnStatuses.active.workedSeconds}
/>
) : null}
{showTurnStatus && isWorking ? (
<NativeChatTurnActivityLine activity={turnActivity} />
<NativeChatTurnActivityLine
activity={turnActivity}
status={turnStatuses.active}
/>
) : null}
{!showTurnStatus && showTypingIndicator ? <NativeChatTypingIndicatorRow /> : null}
</div>
@@ -0,0 +1,563 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import type { NativeChatLiveSession } from './use-native-chat-live-session'
import { NativeChatMessageList } from './NativeChatMessageList'
import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport'
import type {
AgentJournalItemBody,
AgentJournalRenderItem
} from '../../../../shared/agent-session-journal-types'
// The turn record this host writes, and the legacy status row an older host sends.
const turnItem: AgentJournalItemBody = { kind: 'turn', turnId: 'turn-1', state: 'running' }
const legacyTurnRow: AgentJournalItemBody = {
kind: 'status',
text: 'Codex is working…',
turnLifecycle: { turnId: 'turn-1', state: 'running' }
}
const reasoningRow: AgentJournalItemBody = {
kind: 'message',
role: 'reasoning',
blocks: [{ type: 'text', text: '' }]
}
function journalItem(sequence: number, body: AgentJournalItemBody): AgentJournalRenderItem {
return { itemId: `item-${sequence}`, revision: 1, sequence, observedAt: sequence, body }
}
let restoreViewport = (): void => {}
beforeAll(() => {
restoreViewport = installNativeChatMessageListTestViewport()
})
afterAll(() => restoreViewport())
afterEach(cleanup)
const session: NativeChatLiveSession = {
messages: [
{
id: 'assistant-1',
role: 'assistant',
blocks: [{ type: 'text', text: 'Selectable agent response.' }],
timestamp: 1,
source: 'transcript'
}
],
status: 'ready',
sessionId: 'session-1',
agent: 'codex',
hasMore: false,
loadingEarlier: false,
loadEarlier: vi.fn(),
readPhase: 'ready'
}
// The live turn renders exactly one indicator row; a settled turn keeps its own.
describe('NativeChatMessageList turn indicator', () => {
it('keeps a reduced-motion-safe spinner on the live row of a no-tool Codex turn', () => {
render(
<NativeChatMessageList
session={{
...session,
status: 'working',
messages: [
{
id: 'user-prose',
role: 'user',
blocks: [{ type: 'text', text: 'Write a long answer' }],
timestamp: 1,
source: 'transcript'
},
{
id: 'assistant-prose',
role: 'assistant',
blocks: [{ type: 'text', text: 'The answer is still streaming.' }],
timestamp: 2,
source: 'transcript'
}
]
}}
isWorking
expandSignal={false}
fontScale={1}
/>
)
const activity = screen.getByText('Working for 0s')
const row = activity.closest('[data-native-chat-turn-activity]')
const spinner = row?.querySelector('svg')
expect(activity).not.toHaveClass('animate-pulse', 'animate-spin')
expect(spinner).toHaveClass('size-4', 'animate-spin', 'motion-reduce:animate-none')
expect(row).toHaveAttribute('aria-live', 'polite')
expect(screen.getByText('The answer is still streaming.').compareDocumentPosition(row!)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING
)
})
it('keeps the live row distinct from the running tool row', () => {
render(
<NativeChatMessageList
session={{
...session,
status: 'working',
messages: [
{
id: 'assistant-running-tool',
role: 'assistant',
blocks: [
{
type: 'tool-call',
name: 'shell',
input: { command: 'pnpm test' },
state: 'running'
}
],
timestamp: 1,
source: 'transcript'
}
]
}}
isWorking
expandSignal={false}
fontScale={1}
/>
)
const toolLabel = screen.getByText('Running pnpm test')
expect(toolLabel).toHaveClass('animate-pulse')
expect(screen.getAllByText('Running pnpm test')).toHaveLength(1)
const activity = screen.getByText('Working for 0s')
expect(activity.textContent).not.toBe(toolLabel.textContent)
expect(activity).not.toHaveTextContent('shell')
expect(activity).not.toHaveTextContent('pnpm test')
const spinner = activity.closest('[data-native-chat-turn-activity]')?.querySelector('svg')
expect(activity).not.toHaveClass('animate-pulse', 'animate-spin')
expect(spinner).toHaveClass('animate-spin', 'motion-reduce:animate-none')
})
it('keeps the live row up after a tool settles', () => {
render(
<NativeChatMessageList
session={{
...session,
status: 'working',
messages: [
{
id: 'assistant-completed-tool',
role: 'assistant',
blocks: [
{
type: 'tool-call',
name: 'shell',
input: { command: 'pnpm test' },
state: 'completed'
},
{ type: 'tool-result', output: 'passed' }
],
timestamp: 1,
source: 'transcript'
}
]
}}
isWorking
expandSignal={false}
fontScale={1}
/>
)
const settledTool = screen.getByText('shell')
const activity = screen.getByText('Working for 0s')
expect(activity.textContent).not.toBe(settledTool.textContent)
expect(activity).not.toHaveTextContent('shell')
expect(activity).not.toHaveTextContent('pnpm test')
expect(activity).not.toHaveClass('animate-pulse', 'animate-spin')
expect(activity.closest('[data-native-chat-turn-activity]')?.querySelector('svg')).toHaveClass(
'animate-spin'
)
})
it('keeps a completed tool row static while the turn tail spins, then removes the tail', () => {
const workingSession: NativeChatLiveSession = {
...session,
status: 'working',
messages: [
{
id: 'assistant-settled-tool',
role: 'assistant',
blocks: [
{
type: 'tool-call',
name: 'shell',
input: { command: 'pnpm test' },
state: 'completed'
},
{ type: 'tool-result', output: 'passed' }
],
timestamp: 1,
source: 'transcript'
}
]
}
const { container, rerender } = render(
<NativeChatMessageList
session={workingSession}
isWorking
turnActivity={{ kind: 'description', text: 'Preparing the answer' }}
expandSignal={false}
fontScale={1}
/>
)
const settledTool = screen.getByText('shell')
expect(settledTool).toHaveTextContent('shell pnpm test')
expect(settledTool.closest('button')?.querySelector('.animate-pulse')).toBeNull()
expect(settledTool.closest('button')?.querySelector('.lucide-check')).toBeInTheDocument()
const activity = screen.getByText('Preparing the answer')
expect(activity).not.toHaveClass('animate-pulse', 'animate-spin')
expect(activity.closest('[data-native-chat-turn-activity]')?.querySelector('svg')).toHaveClass(
'animate-spin'
)
rerender(
<NativeChatMessageList
session={{ ...workingSession, status: 'ready' }}
isWorking={false}
turnActivity={{ kind: 'description', text: 'Preparing the answer' }}
expandSignal={false}
fontScale={1}
/>
)
expect(container.querySelector('[data-native-chat-turn-activity]')).toBeNull()
expect(container.querySelector('.animate-pulse')).toBeNull()
expect(container.querySelector('.animate-spin')).toBeNull()
})
it('keeps bridge chats on the legacy activity chrome', () => {
render(
<NativeChatMessageList
session={{
...session,
status: 'working',
messages: [
{
id: 'bridge-tool',
role: 'assistant',
blocks: [
{
type: 'tool-call',
name: 'shell',
input: { command: 'sleep 5' },
state: 'running'
}
],
timestamp: 1,
source: 'transcript'
}
]
}}
isWorking
expandSignal={false}
fontScale={1}
showTurnStatus={false}
/>
)
expect(screen.queryByText('Thinking')).toBeNull()
expect(screen.queryByRole('button', { name: 'Toggle turn details' })).toBeNull()
expect(screen.queryByText('Running sleep 5')).toBeNull()
expect(document.querySelectorAll('.animate-bounce')).toHaveLength(3)
})
it('reads "Thinking" on the one live row while the turn is reasoning', () => {
const { container } = render(
<NativeChatMessageList
session={{
...session,
status: 'working',
messages: [
{
id: 'user-thinking',
role: 'user',
blocks: [{ type: 'text', text: 'Start the task' }],
timestamp: Date.now(),
source: 'transcript'
}
]
}}
journalItems={[journalItem(1, turnItem), journalItem(2, reasoningRow)]}
isWorking
expandSignal={false}
fontScale={1}
/>
)
const user = screen.getByText('Start the task')
const thinking = screen.getByText('Thinking')
expect(user.compareDocumentPosition(thinking)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
// One indicator, not a "Thinking" row stacked above a spinning "Working…" row.
expect(container.querySelectorAll('[data-native-chat-turn-status]')).toHaveLength(1)
expect(thinking.closest('[data-native-chat-turn-activity]')?.querySelector('svg')).toHaveClass(
'animate-spin'
)
expect(container.querySelector('.animate-bounce')).toBeNull()
})
it('does not reuse completed-turn reasoning while the next dispatch is pending', () => {
render(
<NativeChatMessageList
session={{
...session,
status: 'working',
messages: [
{
id: 'user-next',
role: 'user',
blocks: [{ type: 'text', text: 'Start the next task' }],
timestamp: Date.now(),
source: 'transcript'
}
]
}}
journalItems={[
journalItem(1, { kind: 'turn', turnId: 'turn-1', state: 'completed' }),
journalItem(2, reasoningRow)
]}
isWorking
expandSignal={false}
fontScale={1}
/>
)
expect(screen.queryByText('Thinking')).toBeNull()
expect(screen.getByText('Working for 0s')).toBeInTheDocument()
})
it('lets provider activity text beat the reasoning label on the same single row', () => {
const { container } = render(
<NativeChatMessageList
session={{
...session,
status: 'working',
messages: [
{
id: 'user-activity',
role: 'user',
blocks: [{ type: 'text', text: 'Start the task' }],
timestamp: Date.now(),
source: 'transcript'
}
]
}}
journalItems={[journalItem(1, legacyTurnRow), journalItem(2, reasoningRow)]}
turnActivity={{ kind: 'description', text: 'Exploring the repo layout' }}
isWorking
expandSignal={false}
fontScale={1}
/>
)
expect(screen.getByText('Exploring the repo layout')).toBeInTheDocument()
expect(screen.queryByText('Thinking')).toBeNull()
expect(container.querySelectorAll('[data-native-chat-turn-status]')).toHaveLength(1)
})
it('places the one live row after the newest content in the turn', () => {
render(
<NativeChatMessageList
session={{
...session,
status: 'working',
messages: [
{
id: 'user-1',
role: 'user',
blocks: [{ type: 'text', text: 'Run the checks' }],
timestamp: 1,
source: 'transcript'
},
{
id: 'assistant-1',
role: 'assistant',
blocks: [{ type: 'text', text: 'I am checking now.' }],
timestamp: 2,
source: 'transcript'
}
]
}}
isWorking
expandSignal={false}
fontScale={1}
/>
)
const status = screen.getByText('Working for 0s')
const assistant = screen.getByText('I am checking now.')
// The live row trails the newest content instead of sitting under the prompt.
expect(assistant.compareDocumentPosition(status)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
expect(document.querySelectorAll('[data-native-chat-turn-status]')).toHaveLength(1)
})
it('shows elapsed working time once tool activity starts', () => {
render(
<NativeChatMessageList
session={{
...session,
status: 'working',
messages: [
{
id: 'tool-1',
role: 'assistant',
blocks: [
{
type: 'tool-call',
name: 'shell',
input: { command: 'sleep 5' },
state: 'running'
}
],
timestamp: 1,
source: 'transcript'
}
]
}}
isWorking
workingStartedAt={Date.now() - 3000}
expandSignal={false}
fontScale={1}
/>
)
expect(screen.getByText('Working for 3s')).toBeInTheDocument()
})
it('keeps the completed duration below the user message', () => {
const startedAt = Date.now() - 3000
const turnSession: NativeChatLiveSession = {
...session,
status: 'working',
messages: [
{
id: 'user-complete',
role: 'user',
blocks: [{ type: 'text', text: 'Complete this task' }],
timestamp: startedAt,
source: 'transcript'
},
{
id: 'assistant-complete',
role: 'assistant',
blocks: [{ type: 'text', text: 'Task complete.' }],
timestamp: Date.now(),
source: 'transcript'
}
]
}
const { rerender } = render(
<NativeChatMessageList
session={turnSession}
isWorking
workingStartedAt={startedAt}
expandSignal={false}
fontScale={1}
/>
)
rerender(
<NativeChatMessageList
session={{ ...turnSession, status: 'ready' }}
isWorking={false}
workingStartedAt={null}
expandSignal={false}
fontScale={1}
/>
)
const user = screen.getByText('Complete this task')
const status = screen.getByText('Worked for 3s')
const assistant = screen.getByText('Task complete.')
expect(user.compareDocumentPosition(status)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
expect(status.compareDocumentPosition(assistant)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
rerender(
<NativeChatMessageList
session={{
...turnSession,
status: 'working',
messages: [
...turnSession.messages,
{
id: 'user-next',
role: 'user',
blocks: [{ type: 'text', text: 'Start another task' }],
timestamp: Date.now(),
source: 'transcript'
}
]
}}
isWorking
workingStartedAt={Date.now()}
expandSignal={false}
fontScale={1}
/>
)
expect(screen.getByText('Worked for 3s')).toBeInTheDocument()
expect(screen.getByText('Working for 0s')).toBeInTheDocument()
})
it("uses the completed caret to expand that turn's tool details", () => {
const startedAt = Date.now() - 3000
render(
<NativeChatMessageList
session={{
...session,
status: 'ready',
messages: [
{
id: 'user-details',
role: 'user',
blocks: [{ type: 'text', text: 'Inspect the repo' }],
timestamp: startedAt,
source: 'transcript'
},
{
id: 'assistant-details',
role: 'assistant',
blocks: [
{
type: 'tool-call',
name: 'shell',
input: { command: 'pwd' },
state: 'completed'
},
{ type: 'tool-result', output: '/repo' }
],
timestamp: Date.now(),
source: 'transcript'
}
]
}}
isWorking={false}
workingStartedAt={startedAt}
expandSignal={false}
fontScale={1}
/>
)
const status = screen.getByRole('button', { name: 'Toggle turn details' })
expect(status).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByRole('button', { name: /1× shell/ })).toBeNull()
fireEvent.click(status)
expect(status).toHaveAttribute('aria-expanded', 'true')
const tool = screen.getByRole('button', { name: /1× shell/ })
expect(tool).toHaveAttribute('aria-expanded', 'true')
expect(screen.getAllByRole('button', { name: /shell pwd/ })[1]).toHaveAttribute(
'aria-expanded',
'false'
)
})
})

Some files were not shown because too many files have changed in this diff Show More