mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 16:02:35 +00:00
Merge remote-tracking branch 'origin/main' into pr20025
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
|
||||
@@ -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}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -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.'
|
||||
}
|
||||
|
||||
|
||||
+20
@@ -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,
|
||||
|
||||
@@ -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[H[2J
|
||||
[6G[38;2;219;177;49m▄[38;2;242;146;46;48;2;246;145;46m▀[38;2;240;114;54;48;2;243;115;55m▀[38;2;240;88;59;49m▄[m
|
||||
[5G[38;2;158;195;69;48;2;134;198;78m▀[38;2;181;180;62;48;2;117;180;94m▀[38;2;226;153;61;48;2;204;149;77m▀[38;2;246;122;52;48;2;239;121;71m▀[38;2;248;106;53;48;2;225;102;82m▀[38;2;239;84;66;48;2;225;79;89m▀[m
|
||||
[4G[38;2;124;194;81;48;2;128;198;84m▀[38;2;113;194;92;48;2;84;184;129m▀[38;2;92;169;143;48;2;64;151;222m▀[38;2;92;145;179;49m▀[38;2;131;115;176m▀[38;2;116;111;195;48;2;74;126;228m▀[38;2;153;93;168;48;2;112;110;206m▀[38;2;156;91;151;48;2;143;100;180m▀[m
|
||||
[38;2;109;198;148m▄[38;2;97;195;125;48;2;98;186;213m▀[38;2;67;174;171;48;2;71;168;220m▀[m [38;2;74;128;234;48;2;61;137;251m▀[38;2;108;115;216;48;2;74;129;240m▀[38;2;101;121;225;49m▄[m
|
||||
[38;2;103;185;244m▄[38;2;107;199;163;48;2;100;182;246m▀[38;2;100;182;246;49m▀[m [38;2;56;134;251m▀[38;2;72;129;244;48;2;56;131;249m▀[38;2;61;133;252;49m▄[m
|
||||
|
||||
Welcome to the [38;2;66;133;244mAntigravity CLI[m. You are currently not signed in.
|
||||
|
||||
⣾ Signing in...␍ [38;2;247;118;142mNo authentication methods available.[m
|
||||
|
||||
Press ctrl+c or ctrl+d twice to exit.[>4m[=0;1u[?1049l[>4;2m[=1;1u[?u[0 q␍[J
|
||||
[6C[38;2;219;177;49m▄[38;2;242;146;46;48;2;246;145;46m▀[38;2;240;114;54;48;2;243;115;55m▀[38;2;240;88;59;49m▄[m [38;2;122;162;247;1mAntigravity CLI 1.2.0[m
|
||||
[34D[38;2;158;195;69;48;2;134;198;78m▀[38;2;181;180;62;48;2;117;180;94m▀[38;2;226;153;61;48;2;204;149;77m▀[38;2;246;122;52;48;2;239;121;71m▀[38;2;248;106;53;48;2;225;102;82m▀[38;2;239;84;66;48;2;225;79;89m▀[m [38;2;154;165;206mGemini API key[m
|
||||
[28D[38;2;124;194;81;48;2;128;198;84m▀[38;2;113;194;92;48;2;84;184;129m▀[38;2;92;169;143;48;2;64;151;222m▀[38;2;92;145;179;49m▀[38;2;131;115;176m▀[38;2;116;111;195;48;2;74;126;228m▀[38;2;153;93;168;48;2;112;110;206m▀[38;2;156;91;151;48;2;143;100;180m▀[m [38;2;154;165;206mGemini 3.7 Flash (Low)[m
|
||||
[38;2;109;198;148m▄[38;2;97;195;125;48;2;98;186;213m▀[38;2;67;174;171;48;2;71;168;220m▀[m [38;2;74;128;234;48;2;61;137;251m▀[38;2;108;115;216;48;2;74;129;240m▀[38;2;101;121;225;49m▄[m [38;2;154;165;206m~[m
|
||||
[38;2;103;185;244m▄[38;2;107;199;163;48;2;100;182;246m▀[38;2;100;182;246;49m▀[m [38;2;56;134;251m▀[38;2;72;129;244;48;2;56;131;249m▀[38;2;61;133;252;49m▄[m
|
||||
|
||||
[38;2;65;72;104m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[m
|
||||
[38;2;122;162;247m>[m
|
||||
[38;2;65;72;104m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[m
|
||||
[38;2;154;165;206m? for shortcuts[m[83X[83C[38;2;154;165;206mGemini 3.7 Flash · low␍[m[2A[2C[?25h[?25lI[?25h[?25ln ab
|
||||
|
||||
[90C[1K [38;2;154;165;206mG[m[2A[92D[?25h[?25lout 8[?25h[?25l0 wo[?25h[?25lrds,[C[?25h[?25lexpla[?25h[?25lin w[?25h[?25lhat a[?25h[?25l pse[?25h[?25lud[?25h[?25loter[?25h[?25lminal[?25h[?25l is.[?25h[?25l[52D[K[?25h[?25l
|
||||
|
||||
[38;2;154;165;206m? for shortcuts[m[2A[13D[?25h[?25lM[58C[K
|
||||
[38;2;122;162;247;1m> In about 80 words, explain what a pseudoterminal is.[m
|
||||
⣷ [38;2;154;165;206mGenerati[38;2;122;162;247mng...[m[K
|
||||
[38;2;65;72;104m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[m
|
||||
[38;2;122;162;247m>[m[K
|
||||
[38;2;65;72;104m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[m
|
||||
[38;2;154;165;206mesc to cancel[m[85X[85C[38;2;154;165;206mGemini 3.7 Flash · low␍[m[2A[2C[?25h[?25l[2A[9C[38;2;154;165;206mn[38;2;122;162;247mg[m
|
||||
|
||||
[11D[?25h[?25l␍[2A⣯ [38;2;154;165;206mGenerating[m
|
||||
|
||||
[11D[?25h[?25l␍[2A⣟ [38;2;154;165;206mGenerating.[m
|
||||
|
||||
[12D[?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␍[J
|
||||
[6C[38;2;219;177;49m▄[38;2;242;146;46;48;2;246;145;46m▀[38;2;240;114;54;48;2;243;115;55m▀[38;2;240;88;59;49m▄[m [38;2;122;162;247;1mAntigravity CLI 1.2.0[m
|
||||
[34D[38;2;158;195;69;48;2;134;198;78m▀[38;2;181;180;62;48;2;117;180;94m▀[38;2;226;153;61;48;2;204;149;77m▀[38;2;246;122;52;48;2;239;121;71m▀[38;2;248;106;53;48;2;225;102;82m▀[38;2;239;84;66;48;2;225;79;89m▀[m [38;2;154;165;206mGemini API key[m
|
||||
[28D[38;2;124;194;81;48;2;128;198;84m▀[38;2;113;194;92;48;2;84;184;129m▀[38;2;92;169;143;48;2;64;151;222m▀[38;2;92;145;179;49m▀[38;2;131;115;176m▀[38;2;116;111;195;48;2;74;126;228m▀[38;2;153;93;168;48;2;112;110;206m▀[38;2;156;91;151;48;2;143;100;180m▀[m [38;2;154;165;206mGemini 3.7 Flash (Low)[m
|
||||
[38;2;109;198;148m▄[38;2;97;195;125;48;2;98;186;213m▀[38;2;67;174;171;48;2;71;168;220m▀[m [38;2;74;128;234;48;2;61;137;251m▀[38;2;108;115;216;48;2;74;129;240m▀[38;2;101;121;225;49m▄[m [38;2;154;165;206m~[m
|
||||
[38;2;103;185;244m▄[38;2;107;199;163;48;2;100;182;246m▀[38;2;100;182;246;49m▀[m [38;2;56;134;251m▀[38;2;72;129;244;48;2;56;131;249m▀[38;2;61;133;252;49m▄[m
|
||||
|
||||
[38;2;65;72;104m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[m
|
||||
[38;2;122;162;247m>[m
|
||||
[38;2;65;72;104m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[m
|
||||
[38;2;154;165;206m? for shortcuts[m[83X[83C[38;2;154;165;206mGemini 3.7 Flash · low[m␍[2A[2C[?25h[?25lIn
|
||||
|
||||
[93C[1K [38;2;154;165;206mG[m[2A[94D[?25h[?25labo[?25h[?25lut 80[?25h[?25l wo[?25h[?25lrds[?25h[?25l, ex[?25h[?25lpla[?25h[?25lin wh[?25h[?25lat a[C[?25h[?25lpseudo[?25h[?25ltermi[?25h[?25lnal is[?25h[?25l.[?25h[?25l[52D[K
|
||||
|
||||
[38;2;154;165;206m? for shortcuts[m[2A[13D[?25h[?25lM[58C[K
|
||||
[38;2;122;162;247;1m> In about 80 words, explain what a pseudoterminal is.[m
|
||||
⣾ [38;2;154;165;206mGen[38;2;122;162;247merati[38;2;154;165;206mng...[m[K
|
||||
[38;2;65;72;104m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[m
|
||||
[38;2;122;162;247m>[m[K
|
||||
[38;2;65;72;104m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[m
|
||||
[38;2;154;165;206mesc to cancel[m[85X[85C[38;2;154;165;206mGemini 3.7 Flash · low␍[m[2A[2C[?25h[?25l␍[2A⣷ [38;2;154;165;206mGene[38;2;122;162;247mratin[m
|
||||
|
||||
[10D[?25h[?25l␍[2A⣯ [38;2;154;165;206mGener[38;2;122;162;247mating[m
|
||||
|
||||
[11D[?25h[?25l␍[2A⣟ [38;2;154;165;206mGenera[38;2;122;162;247mting.[m
|
||||
|
||||
[12D[?25h[?25l␍[2A⡿ [38;2;154;165;206mGenerati[38;2;122;162;247mng...[m
|
||||
|
||||
[14D[?25h[?25l␍[2A⢿ [38;2;154;165;206mGeneratin[m
|
||||
|
||||
[10D[?25h[?25l␍[2A[K
|
||||
[38;2;247;118;142m⚠ Agent execution terminated due to error.[m[K
|
||||
[38;2;154;165;206mError ID: 00000000-0000-4000-8000-000000000000-2[m
|
||||
⢿ [38;2;154;165;206mGenerating[38;2;122;162;247m...[m[K
|
||||
[38;2;65;72;104m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[m
|
||||
[38;2;122;162;247m>[m[K
|
||||
[38;2;65;72;104m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[m
|
||||
[38;2;154;165;206mesc to cancel[m[85X[85C[38;2;154;165;206mGemini 3.7 Flash · low␍[m[2A[2C[?25h[?25l␍[2A[K
|
||||
|
||||
|
||||
|
||||
[38;2;154;165;206m? for shortcuts[m[2A[13D[?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␍[J
|
||||
[6C[38;2;219;177;49m▄[38;2;242;146;46;48;2;246;145;46m▀[38;2;240;114;54;48;2;243;115;55m▀[38;2;240;88;59;49m▄[m [38;2;122;162;247;1mAntigravity CLI 1.2.0[m
|
||||
[34D[38;2;158;195;69;48;2;134;198;78m▀[38;2;181;180;62;48;2;117;180;94m▀[38;2;226;153;61;48;2;204;149;77m▀[38;2;246;122;52;48;2;239;121;71m▀[38;2;248;106;53;48;2;225;102;82m▀[38;2;239;84;66;48;2;225;79;89m▀[m [38;2;154;165;206mGemini API key[m
|
||||
[28D[38;2;124;194;81;48;2;128;198;84m▀[38;2;113;194;92;48;2;84;184;129m▀[38;2;92;169;143;48;2;64;151;222m▀[38;2;92;145;179;49m▀[38;2;131;115;176m▀[38;2;116;111;195;48;2;74;126;228m▀[38;2;153;93;168;48;2;112;110;206m▀[38;2;156;91;151;48;2;143;100;180m▀[m [38;2;154;165;206mGemini 3.7 Flash (Low)[m
|
||||
[38;2;109;198;148m▄[38;2;97;195;125;48;2;98;186;213m▀[38;2;67;174;171;48;2;71;168;220m▀[m [38;2;74;128;234;48;2;61;137;251m▀[38;2;108;115;216;48;2;74;129;240m▀[38;2;101;121;225;49m▄[m [38;2;154;165;206m~[m
|
||||
[38;2;103;185;244m▄[38;2;107;199;163;48;2;100;182;246m▀[38;2;100;182;246;49m▀[m [38;2;56;134;251m▀[38;2;72;129;244;48;2;56;131;249m▀[38;2;61;133;252;49m▄[m
|
||||
|
||||
[38;2;65;72;104m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[m
|
||||
[38;2;122;162;247m>[m
|
||||
[38;2;65;72;104m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[m
|
||||
[38;2;154;165;206m? for shortcuts[m[83X[83C[38;2;154;165;206mGemini 3.7 Flash · low[m␍[2A[2C[?25h[?25l/
|
||||
|
||||
[38;2;122;162;247m> /add-dir [m [38;2;169;177;214mAdd a directory to the workspace[m[K
|
||||
/agents[15X [38;2;169;177;214mList available custom agents[m[K
|
||||
/artifact [38;2;169;177;214mView and review artifacts[m[K
|
||||
/btw[18X [38;2;169;177;214mAsk a side question without interrupting the current task[m[K
|
||||
/changelog [38;2;169;177;214mShow release notes and changes[m[K
|
||||
[38;2;169;177;214m ↓ 50 more[m[K
|
||||
[K
|
||||
[38;2;122;162;247;1m↑/↓[38;2;154;165;206;22m Navigate · [38;2;122;162;247;1menter[38;2;154;165;206;22m Select · [38;2;122;162;247;1mtab[38;2;154;165;206;22m Complete[m[K
|
||||
[53C[1K [38;2;154;165;206mGemini 3.7 Flash · low␍[m[10A[3C[?25h[?25l
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[38;2;154;165;206mesc to cancel[m[10A[10D[?25h[>4m[=0;1u
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[J[?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␍[J
|
||||
[6C[38;2;219;177;49m▄[38;2;242;146;46;48;2;246;145;46m▀[38;2;240;114;54;48;2;243;115;55m▀[38;2;240;88;59;49m▄[m [38;2;122;162;247;1mAntigravity CLI 1.2.0[m
|
||||
[34D[38;2;158;195;69;48;2;134;198;78m▀[38;2;181;180;62;48;2;117;180;94m▀[38;2;226;153;61;48;2;204;149;77m▀[38;2;246;122;52;48;2;239;121;71m▀[38;2;248;106;53;48;2;225;102;82m▀[38;2;239;84;66;48;2;225;79;89m▀[m [38;2;154;165;206mGemini API key[m
|
||||
[28D[38;2;124;194;81;48;2;128;198;84m▀[38;2;113;194;92;48;2;84;184;129m▀[38;2;92;169;143;48;2;64;151;222m▀[38;2;92;145;179;49m▀[38;2;131;115;176m▀[38;2;116;111;195;48;2;74;126;228m▀[38;2;153;93;168;48;2;112;110;206m▀[38;2;156;91;151;48;2;143;100;180m▀[m [38;2;154;165;206mGemini 3.7 Flash (Low)[m
|
||||
[38;2;109;198;148m▄[38;2;97;195;125;48;2;98;186;213m▀[38;2;67;174;171;48;2;71;168;220m▀[m [38;2;74;128;234;48;2;61;137;251m▀[38;2;108;115;216;48;2;74;129;240m▀[38;2;101;121;225;49m▄[m [38;2;154;165;206m~[m
|
||||
[38;2;103;185;244m▄[38;2;107;199;163;48;2;100;182;246m▀[38;2;100;182;246;49m▀[m [38;2;56;134;251m▀[38;2;72;129;244;48;2;56;131;249m▀[38;2;61;133;252;49m▄[m
|
||||
|
||||
[38;2;65;72;104m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[m
|
||||
[38;2;122;162;247m>[m
|
||||
[38;2;65;72;104m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[m
|
||||
[38;2;154;165;206m? for shortcuts[m[83X[83C[38;2;154;165;206mGemini 3.7 Flash · low[m␍[2A[2C[?25h[?25l/mod
|
||||
|
||||
[38;2;122;162;247m> /model[14X[14C[m [38;2;169;177;214mSet a model, or run a single prompt on another model[m[K
|
||||
/permissioned-github [38;2;169;177;214mGuidelines for interacting with GitHub and request permissions from the user when commands f...[m[K
|
||||
[K
|
||||
[38;2;122;162;247;1m↑/↓[38;2;154;165;206;22m Navigate · [38;2;122;162;247;1menter[38;2;154;165;206;22m Select · [38;2;122;162;247;1mtab[38;2;154;165;206;22m Complete[m[K
|
||||
[38;2;154;165;206mesc to cancel[m[85X[85C[38;2;154;165;206mGemini 3.7 Flash · low␍[m[6A[6C[?25h[?25l
|
||||
|
||||
|
||||
|
||||
|
||||
[J[5A[2C[38;2;122;162;247m/model[m
|
||||
|
||||
[14P
|
||||
[K
|
||||
[38;2;122;162;247;1m↑/↓[38;2;154;165;206;22m Navigate · [38;2;122;162;247;1menter[38;2;154;165;206;22m Select · [38;2;122;162;247;1mtab[38;2;154;165;206;22m Complete[m
|
||||
[38;2;154;165;206mesc to cancel[m[85X[85C[38;2;154;165;206mGemini 3.7 Flash · low␍[m[5A[8C[?25h[?25l[0 q[6D[K
|
||||
|
||||
[1mSwitch Model[m[K
|
||||
|
||||
Gemini 3.8 Flash[K
|
||||
[38;2;122;162;247m> Gemini 3.7 Flash[38;2;169;177;214m (current)[m[K
|
||||
Gemini 3.6 Flash[K
|
||||
Gemini 3.1 Pro[K
|
||||
[K
|
||||
Effort [38;2;169;177;214m◂[m [38;2;158;206;106m ◉[38;2;169;177;214m──────────────○──────────────○ [m [38;2;122;162;247m▸[m[K
|
||||
[48D[1K [38;2;158;206;106;1m low [38;2;169;177;214;22m medium high [m[K
|
||||
[46D[1K [38;2;169;177;214;3mFaster responses, lighter reasoning — great for simpler tasks[m[K
|
||||
[K
|
||||
[1mKeyboard: [38;2;122;162;247;22m↑/↓[m [38;2;154;165;206mNavigate[m [38;2;122;162;247m←/→[m [38;2;154;165;206mEffort[m [38;2;122;162;247menter[m [38;2;154;165;206mSelect[m [38;2;122;162;247mesc[m [38;2;154;165;206mGo Back[m[K
|
||||
[K
|
||||
[97C[1K [38;2;154;165;206mGemini 3.7 Flash · low␍[m[0 q[10A[J[6A[38;2;122;162;247;1m> /model[m[K
|
||||
[38;2;122;162;247;1m ⎿ [38;2;169;177;214;22mExited /model command[m
|
||||
[K
|
||||
[38;2;65;72;104m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[m
|
||||
[38;2;122;162;247m>[m
|
||||
[38;2;65;72;104m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[m
|
||||
[98C[38;2;154;165;206mGemini 3.7 Flash · low␍[m[2A[2C[?25h[?25l
|
||||
|
||||
[38;2;154;165;206m? for shortcuts[m[2A[13D[?25h[>4m[=0;1u
|
||||
|
||||
[J[?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␍[J
|
||||
[6C[38;2;219;177;49m▄[38;2;242;146;46;48;2;246;145;46m▀[38;2;240;114;54;48;2;243;115;55m▀[38;2;240;88;59;49m▄[m [38;2;122;162;247;1mAntigravity CLI 1.2.0[m
|
||||
[34D[38;2;158;195;69;48;2;134;198;78m▀[38;2;181;180;62;48;2;117;180;94m▀[38;2;226;153;61;48;2;204;149;77m▀[38;2;246;122;52;48;2;239;121;71m▀[38;2;248;106;53;48;2;225;102;82m▀[38;2;239;84;66;48;2;225;79;89m▀[m [38;2;154;165;206mGemini API key[m
|
||||
[28D[38;2;124;194;81;48;2;128;198;84m▀[38;2;113;194;92;48;2;84;184;129m▀[38;2;92;169;143;48;2;64;151;222m▀[38;2;92;145;179;49m▀[38;2;131;115;176m▀[38;2;116;111;195;48;2;74;126;228m▀[38;2;153;93;168;48;2;112;110;206m▀[38;2;156;91;151;48;2;143;100;180m▀[m [38;2;154;165;206mGemini 3.7 Flash (Low)[m
|
||||
[38;2;109;198;148m▄[38;2;97;195;125;48;2;98;186;213m▀[38;2;67;174;171;48;2;71;168;220m▀[m [38;2;74;128;234;48;2;61;137;251m▀[38;2;108;115;216;48;2;74;129;240m▀[38;2;101;121;225;49m▄[m [38;2;154;165;206m~[m
|
||||
[38;2;103;185;244m▄[38;2;107;199;163;48;2;100;182;246m▀[38;2;100;182;246;49m▀[m [38;2;56;134;251m▀[38;2;72;129;244;48;2;56;131;249m▀[38;2;61;133;252;49m▄[m
|
||||
|
||||
[38;2;65;72;104m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[m
|
||||
[38;2;122;162;247m>[m
|
||||
[38;2;65;72;104m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[m
|
||||
[38;2;154;165;206m? for shortcuts[m[83X[83C[38;2;154;165;206mGemini 3.7 Flash · low[m␍[2A[2C[?25h[?25l/mo
|
||||
|
||||
[38;2;122;162;247m> /model[14X[14C[m [38;2;169;177;214mSet a model, or run a single prompt on another model[m[K
|
||||
/migrate-workflows [38;2;169;177;214mAutomatically migrate legacy workflows to modern skills across global and workspace configur...[m[K
|
||||
/permissions [38;2;169;177;214mManage tool permissions[m[K
|
||||
/agy-customizations [38;2;169;177;214mComprehensive guide and reference for the Antigravity Customization System. Use to explain h...[m[K
|
||||
/permissioned-github [38;2;169;177;214mGuidelines for interacting with GitHub and request permissions from the user when commands f...[m[K
|
||||
[K
|
||||
[38;2;122;162;247;1m↑/↓[38;2;154;165;206;22m Navigate · [38;2;122;162;247;1menter[38;2;154;165;206;22m Select · [38;2;122;162;247;1mtab[38;2;154;165;206;22m Complete[m[K
|
||||
[38;2;154;165;206m? for shortcuts[m[83X[83C[38;2;154;165;206mGemini 3.7 Flash · low␍[m[9A[5C[?25h[?25l
|
||||
|
||||
|
||||
|
||||
|
||||
[J[5A[2C[38;2;122;162;247m/model[m
|
||||
|
||||
[14P
|
||||
[K
|
||||
[38;2;122;162;247;1m↑/↓[38;2;154;165;206;22m Navigate · [38;2;122;162;247;1menter[38;2;154;165;206;22m Select · [38;2;122;162;247;1mtab[38;2;154;165;206;22m Complete[m[K
|
||||
[38;2;154;165;206mesc to cancel[m[85X[85C[38;2;154;165;206mGemini 3.7 Flash · low␍[m[5A[8C[?25h[?25l[0 q[6D[K
|
||||
|
||||
[1mSwitch Model[m[K
|
||||
|
||||
[38;2;122;162;247m> Gemini 3.8 Flash[m[K
|
||||
Gemini 3.7 Flash[38;2;169;177;214m (current)[m[K
|
||||
Gemini 3.6 Flash[K
|
||||
Gemini 3.1 Pro[K
|
||||
[K
|
||||
Effort [38;2;122;162;247m◂[m [38;2;255;158;100m ●━━━━━━━━━━━━━━◉[38;2;169;177;214m──────────────○ [m [38;2;122;162;247m▸[m[K
|
||||
[48D[1K [38;2;169;177;214m low [38;2;255;158;100;1m medium [38;2;169;177;214;22m high [m[K
|
||||
[46D[1K [38;2;169;177;214;3mBalanced speed and reasoning quality for most tasks[m[K
|
||||
[K
|
||||
[1mKeyboard: [38;2;122;162;247;22m↑/↓[m [38;2;154;165;206mNavigate[m [38;2;122;162;247m←/→[m [38;2;154;165;206mEffort[m [38;2;122;162;247menter[m [38;2;154;165;206mSelect[m [38;2;122;162;247mesc[m [38;2;154;165;206mGo Back[m[K
|
||||
[K
|
||||
[38;2;154;165;206m? for shortcuts[m[83X[83C[38;2;154;165;206mGemini 3.7 Flash · low␍[m[11A Gemini 3.8 Flash
|
||||
[38;2;122;162;247m> Gemini 3.7 Flash[m
|
||||
|
||||
|
||||
|
||||
[8D[38;2;169;177;214m◂[m [38;2;158;206;106m ◉[38;2;169;177;214m──────────────○[m
|
||||
[23D[38;2;158;206;106;1m low [38;2;169;177;214;22m medium [m
|
||||
[30D[38;2;169;177;214;3mFaster responses, lighter reasoning — great for simpler tasks[m
|
||||
|
||||
|
||||
|
||||
[1K [38;2;154;165;206mG[m[>4m[=0;1u␍[J[?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[?u[H[2J[38;2;224;175;104;1mAccessing workspace:[m
|
||||
|
||||
[1m/private/tmp/agy-trust-scratch-77950[m
|
||||
|
||||
Do you trust the contents of this project?
|
||||
|
||||
[38;2;66;133;244mAntigravity CLI[m requires permission to read, edit, and execute files here.
|
||||
|
||||
[38;2;122;162;247m> Yes, I trust this folder[m
|
||||
[38;2;154;165;206mNo, exit[m
|
||||
|
||||
[38;2;122;162;247;1m↑/↓[38;2;154;165;206;22m Navigate · [38;2;122;162;247;1menter[38;2;154;165;206;22m Confirm[m[13;99H[38;2;154;165;206mGemini 3.7 Flash · low[m[>4m[=0;1u␍[40d[?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␍[J
|
||||
[6C[38;2;219;177;49m▄[38;2;242;146;46;48;2;246;145;46m▀[38;2;240;114;54;48;2;243;115;55m▀[38;2;240;88;59;49m▄[m [38;2;122;162;247;1mAntigravity CLI 1.2.0[m
|
||||
[34D[38;2;158;195;69;48;2;134;198;78m▀[38;2;181;180;62;48;2;117;180;94m▀[38;2;226;153;61;48;2;204;149;77m▀[38;2;246;122;52;48;2;239;121;71m▀[38;2;248;106;53;48;2;225;102;82m▀[38;2;239;84;66;48;2;225;79;89m▀[m [38;2;154;165;206mGemini 3.7 Flash (Low)[m
|
||||
[36D[38;2;124;194;81;48;2;128;198;84m▀[38;2;113;194;92;48;2;84;184;129m▀[38;2;92;169;143;48;2;64;151;222m▀[38;2;92;145;179;49m▀[38;2;131;115;176m▀[38;2;116;111;195;48;2;74;126;228m▀[38;2;153;93;168;48;2;112;110;206m▀[38;2;156;91;151;48;2;143;100;180m▀[m [38;2;154;165;206m~[m
|
||||
[38;2;109;198;148m▄[38;2;97;195;125;48;2;98;186;213m▀[38;2;67;174;171;48;2;71;168;220m▀[m [38;2;74;128;234;48;2;61;137;251m▀[38;2;108;115;216;48;2;74;129;240m▀[38;2;101;121;225;49m▄[m
|
||||
[38;2;103;185;244m▄[38;2;107;199;163;48;2;100;182;246m▀[38;2;100;182;246;49m▀[m [38;2;56;134;251m▀[38;2;72;129;244;48;2;56;131;249m▀[38;2;61;133;252;49m▄[m
|
||||
|
||||
[38;2;65;72;104m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[m
|
||||
[38;2;122;162;247m>[m
|
||||
[38;2;65;72;104m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[m
|
||||
[38;2;154;165;206m? for shortcuts[m[83X[83C[38;2;154;165;206mGemini 3.7 Flash · low[m␍[2A[2C[?25h[>4m[=0;1u
|
||||
|
||||
[J[?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␍[J
|
||||
[6C[38;2;219;177;49m▄[38;2;242;146;46;48;2;246;145;46m▀[38;2;240;114;54;48;2;243;115;55m▀[38;2;240;88;59;49m▄[m [38;2;122;162;247;1mAntigravity CLI 1.2.0[m
|
||||
[34D[38;2;158;195;69;48;2;134;198;78m▀[38;2;181;180;62;48;2;117;180;94m▀[38;2;226;153;61;48;2;204;149;77m▀[38;2;246;122;52;48;2;239;121;71m▀[38;2;248;106;53;48;2;225;102;82m▀[38;2;239;84;66;48;2;225;79;89m▀[m [38;2;154;165;206mGemini API key[m
|
||||
[28D[38;2;124;194;81;48;2;128;198;84m▀[38;2;113;194;92;48;2;84;184;129m▀[38;2;92;169;143;48;2;64;151;222m▀[38;2;92;145;179;49m▀[38;2;131;115;176m▀[38;2;116;111;195;48;2;74;126;228m▀[38;2;153;93;168;48;2;112;110;206m▀[38;2;156;91;151;48;2;143;100;180m▀[m [38;2;154;165;206mGemini 3.7 Flash (Low)[m
|
||||
[38;2;109;198;148m▄[38;2;97;195;125;48;2;98;186;213m▀[38;2;67;174;171;48;2;71;168;220m▀[m [38;2;74;128;234;48;2;61;137;251m▀[38;2;108;115;216;48;2;74;129;240m▀[38;2;101;121;225;49m▄[m [38;2;154;165;206m~[m
|
||||
[38;2;103;185;244m▄[38;2;107;199;163;48;2;100;182;246m▀[38;2;100;182;246;49m▀[m [38;2;56;134;251m▀[38;2;72;129;244;48;2;56;131;249m▀[38;2;61;133;252;49m▄[m
|
||||
|
||||
[38;2;65;72;104m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[m
|
||||
[38;2;122;162;247m>[m
|
||||
[38;2;65;72;104m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[m
|
||||
[38;2;154;165;206m? for shortcuts[m[83X[83C[38;2;154;165;206mGemini 3.7 Flash · low[m␍[2A[2C[?25h[>4m[=0;1u
|
||||
|
||||
[J[?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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+563
@@ -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
Reference in New Issue
Block a user