Merge remote-tracking branch 'origin/main' into brennanb2025/fix-codex-account-switch

# Conflicts:
#	src/renderer/src/i18n/locales/en.json
#	src/renderer/src/i18n/locales/es.json
#	src/renderer/src/i18n/locales/ja.json
#	src/renderer/src/i18n/locales/ko.json
#	src/renderer/src/i18n/locales/zh.json
This commit is contained in:
Brennan Benson
2026-07-27 17:43:39 -07:00
143 changed files with 10341 additions and 1867 deletions
@@ -0,0 +1,190 @@
#!/usr/bin/env node
// Benchmark: the head-of-chain reads in getBranchCompare (src/main/git/status.ts).
//
// Four spawns ran strictly in series before any compare work started: branch
// --show-current, the base-ref probe, rev-parse HEAD, and rev-parse <base>. compareRef is
// display-only metadata and HEAD's oid does not depend on the base ref, so the first three
// can overlap. The probe oid also replaces the fourth spawn when it proves refs/heads/*;
// remote-tracking refs require a raw rev-parse because they may store annotated tags.
//
// This spawns the real git binary against this repo, so it measures actual process-launch
// cost rather than a model of it. Over SSH these are host-local spawns inside the relay,
// so the saving applies to remote spawn time, not to network round trips.
//
// Both arms are compared for identical resolved values before timing.
//
// Run with: node config/scripts/branch-compare-head-benchmark.mjs
import { execFile } from 'node:child_process'
import { performance } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'
import { readBranchCompareHead } from '../../src/shared/git-branch-compare-head.ts'
const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url))
const ITERATIONS = Number(process.env.ORCA_BRANCH_COMPARE_BENCH_ITERATIONS ?? '8')
const WARMUP = Number(process.env.ORCA_BRANCH_COMPARE_BENCH_WARMUP ?? '2')
const ROUNDS = 6
for (const [name, value] of [
['ORCA_BRANCH_COMPARE_BENCH_ITERATIONS', ITERATIONS],
['ORCA_BRANCH_COMPARE_BENCH_WARMUP', WARMUP]
]) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, received ${value}`)
}
}
function git(args) {
return new Promise((resolve, reject) => {
execFile('git', args, { cwd: REPO_ROOT, maxBuffer: 64 * 1024 * 1024 }, (error, stdout) =>
error ? reject(error) : resolve(stdout.trim())
)
})
}
async function probeOid(qualifiedRef) {
try {
const out = await git(['rev-parse', '--verify', '--quiet', `${qualifiedRef}^{commit}`])
return out.length > 0 ? out : null
} catch {
return null
}
}
// Pre-fix: serial chain, and the probe's oid discarded then re-resolved.
async function readSerial(baseRef) {
const compareRef = (await git(['branch', '--show-current']).catch(() => '')) || 'HEAD'
let resolvedBaseRef = baseRef
if (!baseRef.startsWith('refs/')) {
const candidates = baseRef.includes('/')
? [`refs/remotes/${baseRef}`, `refs/heads/${baseRef}`]
: [`refs/heads/${baseRef}`]
for (const candidate of candidates) {
if ((await probeOid(candidate)) !== null) {
resolvedBaseRef = candidate
break
}
}
}
const headOid = await git(['rev-parse', '--verify', '--end-of-options', 'HEAD'])
const baseOid = await git(['rev-parse', '--verify', '--end-of-options', resolvedBaseRef])
return { compareRef, resolvedBaseRef, headOid, baseOid }
}
// Production head reader: overlaps independent reads and reuses only safe probe oids.
async function readConcurrent(baseRef) {
const reusableProbedOidByRef = new Map()
const resolveBaseRef = async () => {
if (baseRef.startsWith('refs/')) {
return baseRef
}
const candidates = baseRef.includes('/')
? [`refs/remotes/${baseRef}`, `refs/heads/${baseRef}`]
: [`refs/heads/${baseRef}`]
for (const candidate of candidates) {
const oid = await probeOid(candidate)
if (oid !== null) {
if (candidate.startsWith('refs/heads/')) {
reusableProbedOidByRef.set(candidate, oid)
}
return candidate
}
}
return baseRef
}
const result = await readBranchCompareHead({
readCompareRef: () =>
git(['branch', '--show-current'])
.then((out) => out || 'HEAD')
.catch(() => 'HEAD'),
resolveBaseRef,
readHeadOid: () => git(['rev-parse', '--verify', '--end-of-options', 'HEAD']),
readBaseOid: (resolvedBaseRef) => {
const reusableOid = reusableProbedOidByRef.get(resolvedBaseRef)
return reusableOid === undefined
? git(['rev-parse', '--verify', '--end-of-options', resolvedBaseRef])
: Promise.resolve(reusableOid)
}
})
if (!result.headOidResult.ok) {
throw result.headOidResult.error
}
if (!result.baseOidResult.ok) {
throw result.baseOidResult.error
}
return {
compareRef: result.compareRef,
resolvedBaseRef: result.resolvedBaseRef,
headOid: result.headOidResult.oid,
baseOid: result.baseOidResult.oid
}
}
function median(samples) {
const sorted = [...samples].sort((a, b) => a - b)
const mid = sorted.length / 2
return (sorted[mid - 1] + sorted[mid]) / 2
}
async function timeArm(read, baseRef) {
const start = performance.now()
for (let index = 0; index < ITERATIONS; index += 1) {
await read(baseRef)
}
return (performance.now() - start) / ITERATIONS
}
// Arms alternate which one leads so within-round drift cannot favour either.
async function measure(baseRef) {
for (let index = 0; index < WARMUP; index += 1) {
await readSerial(baseRef)
await readConcurrent(baseRef)
}
const serialSamples = []
const concurrentSamples = []
for (let round = 0; round < ROUNDS; round += 1) {
if (round % 2 === 0) {
serialSamples.push(await timeArm(readSerial, baseRef))
concurrentSamples.push(await timeArm(readConcurrent, baseRef))
} else {
concurrentSamples.push(await timeArm(readConcurrent, baseRef))
serialSamples.push(await timeArm(readSerial, baseRef))
}
}
return { serialMs: median(serialSamples), concurrentMs: median(concurrentSamples) }
}
const pad = (value, width) => String(value).padStart(width)
console.log('getBranchCompare head-of-chain reads, per call. Lower is better.')
console.log(`iterations=${ITERATIONS} warmup=${WARMUP} rounds=${ROUNDS} (per-arm medians)`)
console.log(
`${pad('base ref', 30)} ${pad('serial', 11)} ${pad('concurrent', 11)} ${pad('speedup', 9)}`
)
// A short remote label is the common case (Orca's base picker emits `origin/main`); the
// already-qualified ref skips the probe entirely, so only the concurrency half applies.
const upstream = await git(['rev-parse', '--abbrev-ref', 'HEAD@{upstream}']).catch(() => null)
const baseRefs = ['origin/main', 'refs/remotes/origin/main', 'main']
if (upstream && !baseRefs.includes(upstream)) {
baseRefs.push(upstream)
}
for (const baseRef of baseRefs) {
const serial = await readSerial(baseRef)
const concurrent = await readConcurrent(baseRef)
if (JSON.stringify(serial) !== JSON.stringify(concurrent)) {
throw new Error(
`resolved values differ for ${baseRef}:\n serial ${JSON.stringify(serial)}\n concurrent ${JSON.stringify(concurrent)}`
)
}
if (!serial.headOid) {
throw new Error(`fixture resolved no HEAD oid for ${baseRef}`)
}
const { serialMs, concurrentMs } = await measure(baseRef)
console.log(
`${pad(baseRef, 30)} ${pad(`${serialMs.toFixed(1)} ms`, 11)} ${pad(`${concurrentMs.toFixed(1)} ms`, 11)} ${pad(`${(serialMs / concurrentMs).toFixed(2)}x`, 9)}`
)
}
console.log(
'\nThe already-qualified refs/... row skips the probe by design, so it only shows the\nconcurrency half. This times the native/WSL head-of-chain reads, not the whole compare;\nthe relay path has separate production-concurrency coverage.'
)
@@ -0,0 +1,282 @@
#!/usr/bin/env node
// Benchmark: CLI process startup with the RuntimeClient module graph deferred.
//
// src/cli/index.ts used to value-import RuntimeClient at module scope, and five
// modules that load on every invocation (args, flags, dispatch, format,
// selectors) pulled RuntimeClientError from the ./runtime-client barrel. Either
// edge alone drags in the whole client graph: zod (via shared/pairing ->
// shared/mobile-relay-pairing-offer), ws + tweetnacl (via websocket-transport),
// plus the environment store and secure-file stack.
//
// The fix repoints those five at ./runtime/types (zero children) and loads the
// client through `await import()` after flag validation, so --help, `help
// <cmd>` and every command/flag error return without ever touching it.
//
// Both arms are REAL tsc emits of real source: the baseline arm restores the
// seven touched files from a git rev and compiles that. Each sample is a FRESH
// process (module-graph cost is a once-per-process cost; timing it in-process
// would measure a warm require cache).
//
// Arms alternate lead across an even number of rounds and report per-arm
// medians. Byte-for-byte output equality is checked BEFORE timing.
import { execFileSync, spawnSync } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { performance } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'
const REPO = fileURLToPath(new URL('../..', import.meta.url))
const ROUNDS = Number(process.env.ORCA_CLI_DEFER_BENCH_ROUNDS ?? '30')
const WARMUP = Number(process.env.ORCA_CLI_DEFER_BENCH_WARMUP ?? '3')
for (const [name, value] of [
['ORCA_CLI_DEFER_BENCH_ROUNDS', ROUNDS],
['ORCA_CLI_DEFER_BENCH_WARMUP', WARMUP]
]) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, received ${value}`)
}
}
if (ROUNDS % 2 !== 0) {
// Why: arms alternate which one leads; an odd count biases one arm.
throw new Error(`ORCA_CLI_DEFER_BENCH_ROUNDS must be even, received ${ROUNDS}`)
}
const TOUCHED = [
'src/cli/args.ts',
'src/cli/dispatch.ts',
'src/cli/flags.ts',
'src/cli/format.ts',
'src/cli/index.ts',
'src/cli/runtime/client.ts',
'src/cli/selectors.ts'
]
// Why: if the deferral is reverted or reshaped, both arms would compile to the
// same thing and this would quietly report 1.00x forever. Re-read the real call
// forms out of the source. Matching the CALL form (not a bare identifier) so a
// comment that merely names the function cannot satisfy the check.
function assertMarkersFresh() {
const checks = [
['src/cli/index.ts', "await import('./runtime-client.js')"],
['src/cli/index.ts', 'await loadRuntimeClientClass()'],
['src/cli/index.ts', "import type { RuntimeClient } from './runtime-client'"],
['src/cli/runtime/client.ts', "await import('./websocket-transport.js')"],
['src/cli/runtime/client.ts', 'await loadSendWebSocketRequest()'],
['src/cli/args.ts', "import { RuntimeClientError } from './runtime/types'"],
['src/cli/flags.ts', "import { RuntimeClientError } from './runtime/types'"],
['src/cli/dispatch.ts', "import { RuntimeClientError } from './runtime/types'"],
['src/cli/selectors.ts', "import { RuntimeClientError } from './runtime/types'"],
['src/cli/format.ts', "} from './runtime/types'"]
]
for (const [file, marker] of checks) {
if (!readFileSync(join(REPO, file), 'utf8').includes(marker)) {
throw new Error(
`${file} no longer contains \`${marker}\` — cli-runtime-client-deferral-benchmark.mjs is stale`
)
}
}
}
function buildArm(label, baselineRev) {
// Why: a build under /tmp cannot resolve the repo's node_modules, so the
// output has to live inside the repo.
const outDir = join(REPO, `.bench-out-${label}`)
rmSync(outDir, { recursive: true, force: true })
const restore = []
try {
if (baselineRev) {
for (const file of TOUCHED) {
const path = join(REPO, file)
restore.push([path, readFileSync(path)])
writeFileSync(
path,
execFileSync('git', ['show', `${baselineRev}:${file}`], {
cwd: REPO,
maxBuffer: 64 * 1024 * 1024
})
)
}
}
execFileSync(
'npx',
[
'tsc',
'-p',
'config/tsconfig.cli.json',
'--outDir',
outDir,
'--composite',
'false',
'--incremental',
'false'
],
{ cwd: REPO, stdio: 'inherit' }
)
} finally {
for (const [path, contents] of restore) {
writeFileSync(path, contents)
}
}
return join(outDir, 'cli/index.js')
}
function run(entry, argv, env) {
const result = spawnSync(process.execPath, [entry, ...argv], {
cwd: REPO,
env: { ...process.env, ...env },
encoding: 'buffer'
})
if (result.error) {
throw result.error
}
return {
status: result.status,
stdout: result.stdout.toString('utf8'),
stderr: result.stderr.toString('utf8')
}
}
// Counts the eager CommonJS module graph of a built entry point by hooking
// Module._load in a child process. This is the quantity the change moves.
function countEagerModules(entry) {
const probe = `
const Module = require('module')
const original = Module._load
const seen = new Set()
Module._load = function (request, parent, isMain) {
try { seen.add(Module._resolveFilename(request, parent, isMain)) } catch { seen.add(request) }
return original.apply(this, arguments)
}
require(${JSON.stringify(entry)})
const all = [...seen]
process.stdout.write(JSON.stringify({
total: all.length,
nodeModules: all.filter((p) => p.includes('node_modules')).length
}))
`
const result = spawnSync(process.execPath, ['-e', probe], { cwd: REPO, encoding: 'utf8' })
if (result.status !== 0) {
throw new Error(`module probe failed: ${result.stderr}`)
}
return JSON.parse(result.stdout)
}
const median = (values) => {
const sorted = [...values].sort((a, b) => a - b)
return sorted[Math.floor(sorted.length / 2)]
}
const baselineIndex = process.argv.indexOf('--baseline')
const baselineRev = baselineIndex === -1 ? 'HEAD' : process.argv[baselineIndex + 1]
assertMarkersFresh()
const userDataPath = mkdtempSync(join(REPO, '.bench-userdata-'))
try {
console.log(`Building eager baseline (${baselineRev}) …`)
const eagerEntry = buildArm('eager', baselineRev)
console.log('Building deferred (working tree) …')
const deferredEntry = buildArm('deferred', null)
const eagerGraph = countEagerModules(eagerEntry)
const deferredGraph = countEagerModules(deferredEntry)
console.log(
`\nEager modules at process load: ${eagerGraph.total} -> ${deferredGraph.total} ` +
`(node_modules ${eagerGraph.nodeModules} -> ${deferredGraph.nodeModules})`
)
if (deferredGraph.total >= eagerGraph.total) {
throw new Error(
'deferred arm loads no fewer modules — the fixture does not exercise the change'
)
}
// Each case is (label, argv, env). The runtime-dependent ones point at an
// empty user-data dir so both arms get the same deterministic answer.
const isolated = { ORCA_USER_DATA_PATH: userDataPath }
const cases = [
['orca --help', ['--help'], {}],
['orca help worktree', ['help', 'worktree'], {}],
['orca (no args)', [], {}],
['unknown command', ['no-such-command'], {}],
['unknown flag', ['worktree', 'list', '--nope'], {}],
['orca agent-context --json', ['agent-context', '--json'], {}],
['orca status --json', ['status', '--json'], isolated],
['orca worktree list --json', ['worktree', 'list', '--json'], isolated]
]
// Why: a semantically broken arm that prints nothing would look fastest.
// Compare bytes and exit codes BEFORE timing anything.
for (const [label, argv, env] of cases) {
const before = run(eagerEntry, argv, env)
const after = run(deferredEntry, argv, env)
if (
before.status !== after.status ||
before.stdout !== after.stdout ||
before.stderr !== after.stderr
) {
throw new Error(`arms disagree for "${label}" — refusing to report a timing`)
}
if (before.stdout.length + before.stderr.length === 0) {
throw new Error(`"${label}" produced no output on either arm; it proves nothing`)
}
}
const pad = (value, width) => String(value).padStart(width)
console.log('\nFresh process per sample, wall clock. Lower is better.')
console.log(`rounds=${ROUNDS} warmup=${WARMUP} (per-arm median, arms alternate lead)`)
console.log(`${pad('case', 26)} ${pad('eager', 10)} ${pad('deferred', 10)} ${pad('speedup', 9)}`)
// Accumulated so V8 cannot treat the spawn loop as dead code.
let consumed = 0
for (const [label, argv, env] of cases) {
for (let index = 0; index < WARMUP; index += 1) {
consumed += run(eagerEntry, argv, env).stdout.length
consumed += run(deferredEntry, argv, env).stdout.length
}
const samples = { eager: [], deferred: [] }
for (let round = 0; round < ROUNDS; round += 1) {
// Alternate which arm leads so a drifting machine load cannot be
// attributed to one arm.
const order =
round % 2 === 0
? [
['eager', eagerEntry],
['deferred', deferredEntry]
]
: [
['deferred', deferredEntry],
['eager', eagerEntry]
]
for (const [arm, entry] of order) {
const started = performance.now()
const result = run(entry, argv, env)
samples[arm].push(performance.now() - started)
consumed += result.stdout.length
}
}
const eagerMs = median(samples.eager)
const deferredMs = median(samples.deferred)
console.log(
`${pad(label, 26)} ${pad(`${eagerMs.toFixed(1)} ms`, 10)} ${pad(`${deferredMs.toFixed(1)} ms`, 10)} ${pad(`${(eagerMs / deferredMs).toFixed(2)}x`, 9)}`
)
}
if (consumed === 0) {
throw new Error('no output consumed — the timing loop was optimised away')
}
console.log(
'\nThe help and error rows are the ones the change targets: they return\n' +
'before any client construction, so they drop the whole graph. `status` and\n' +
'`worktree list` still construct a client, so they only save the eager parse\n' +
'of the parts the local path never uses (ws/tweetnacl via websocket-transport).'
)
} finally {
rmSync(userDataPath, { recursive: true, force: true })
for (const label of ['eager', 'deferred']) {
rmSync(join(REPO, `.bench-out-${label}`), { recursive: true, force: true })
}
}
@@ -0,0 +1,485 @@
#!/usr/bin/env node
// Equivalence check for deferring the RuntimeClient module graph in the CLI.
//
// Builds the CLI twice with the REAL tsc emit — once from the working tree and
// once with the seven touched files restored from git HEAD~ (the pre-deferral
// implementation) — then compares stdout, stderr and exit code BYTE FOR BYTE
// across a matrix of invocations.
//
// Why a script and not a vitest case: this compiles two full CLI trees. It is
// the artifact that proves the refactor is behaviour-preserving; the fast
// invariants (class identity, no eager barrel import) live in
// src/cli/runtime-client-deferral.test.ts and run in the normal suite.
//
// Usage: node config/scripts/cli-runtime-client-deferral-equivalence.mjs [--baseline <rev>]
import { execFileSync, spawnSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const REPO = fileURLToPath(new URL('../..', import.meta.url))
// The files this change touches. Restoring exactly these from the baseline rev
// reconstructs the old implementation without disturbing anything else.
const TOUCHED = [
'src/cli/args.ts',
'src/cli/dispatch.ts',
'src/cli/flags.ts',
'src/cli/format.ts',
'src/cli/index.ts',
'src/cli/runtime/client.ts',
'src/cli/selectors.ts'
]
// Why: if the deferral is ever reverted or reshaped, the "old" arm would
// silently become identical to the new one and every case would pass
// vacuously. Re-read the real call form out of the source and fail loudly.
function assertMarkersFresh() {
const index = readFileSync(join(REPO, 'src/cli/index.ts'), 'utf8')
const client = readFileSync(join(REPO, 'src/cli/runtime/client.ts'), 'utf8')
const checks = [
['src/cli/index.ts', index, 'await loadRuntimeClientClass()'],
['src/cli/index.ts', index, "await import('./runtime-client.js')"],
['src/cli/index.ts', index, "import type { RuntimeClient } from './runtime-client'"],
['src/cli/runtime/client.ts', client, 'await loadSendWebSocketRequest()'],
['src/cli/runtime/client.ts', client, "await import('./websocket-transport.js')"]
]
for (const [file, source, marker] of checks) {
// Match the call form, not a bare word: a comment naming the function must
// not satisfy the check.
if (!source.includes(marker)) {
throw new Error(
`${file} no longer contains \`${marker}\` — cli-runtime-client-deferral-equivalence.mjs is stale`
)
}
}
const repointed = ['src/cli/args.ts', 'src/cli/flags.ts', 'src/cli/dispatch.ts']
for (const file of repointed) {
const source = readFileSync(join(REPO, file), 'utf8')
if (!source.includes("import { RuntimeClientError } from './runtime/types'")) {
throw new Error(`${file} no longer repoints RuntimeClientError at ./runtime/types — stale`)
}
}
}
function buildTree(label, baselineRev) {
// Why: builds under /tmp cannot resolve the repo's node_modules, so the
// output dir has to live inside the repo.
const outDir = join(REPO, `.equiv-out-${label}`)
rmSync(outDir, { recursive: true, force: true })
const restored = []
try {
if (baselineRev) {
for (const file of TOUCHED) {
const path = join(REPO, file)
restored.push([path, readFileSync(path)])
const old = execFileSync('git', ['show', `${baselineRev}:${file}`], {
cwd: REPO,
maxBuffer: 64 * 1024 * 1024
})
writeFileSync(path, old)
}
}
execFileSync(
'npx',
[
'tsc',
'-p',
'config/tsconfig.cli.json',
'--outDir',
outDir,
'--composite',
'false',
'--incremental',
'false'
],
{ cwd: REPO, stdio: 'inherit' }
)
} finally {
for (const [path, contents] of restored) {
writeFileSync(path, contents)
}
}
return join(outDir, 'cli/index.js')
}
// Why: every case is a one-shot CLI invocation that must exit on its own. An
// unbounded spawnSync turns "this argv reached a blocking command" into an
// indefinite stall — a guard that can hang instead of failing is not a guard.
const RUN_TIMEOUT_MS = 30_000
function run(entry, argv, env) {
const result = spawnSync(process.execPath, [entry, ...argv], {
cwd: REPO,
env: { ...process.env, ...env },
encoding: 'buffer',
timeout: RUN_TIMEOUT_MS,
killSignal: 'SIGKILL'
})
if (result.error) {
if (result.error.code === 'ETIMEDOUT') {
throw new Error(
`orca ${argv.join(' ')} did not exit within ${RUN_TIMEOUT_MS} ms — it reached a blocking command`
)
}
throw result.error
}
return {
status: result.status,
stdout: result.stdout.toString('utf8'),
stderr: result.stderr.toString('utf8')
}
}
// Every case must produce identical bytes on both arms. The runtime-dependent
// ones (status/worktree list) are pointed at an empty user-data dir so the
// answer is deterministic "not running" rather than whatever the dev machine
// happens to be doing.
function buildCases(isolatedUserData) {
const isolated = { ORCA_USER_DATA_PATH: isolatedUserData }
const cases = [
// Paths that must never load the runtime client at all.
[[], {}],
[['--help'], {}],
[['help'], {}],
[['help', 'worktree'], {}],
[['help', 'browser'], {}],
[['worktree', '--help'], {}],
[['help', 'no-such-command'], {}],
[['no-such-command'], {}],
[['no-such-command'], { ORCA_PAIRING_CODE: 'garbage' }],
[['wrktree', 'list'], {}],
[['agent-context'], {}],
[['agent-context', '--json'], {}],
// Flag validation must still fire before any runtime lookup.
[['worktree', 'list', '--nonexistent-flag'], {}],
[['worktree', 'list', '--nonexistent-flag', '--json'], {}],
[['browser', 'snapshot', '--nonexistent-flag'], {}],
// resolveRemotePairing throws from the RuntimeClient CONSTRUCTOR.
[['status', '--pairing-code', 'x', '--environment', 'y'], isolated],
[['status', '--pairing-code', 'x', '--environment', 'y', '--json'], isolated],
[['status', '--pairing-code', 'not-a-pairing-code'], isolated],
[['status', '--pairing-code', 'not-a-pairing-code', '--json'], isolated],
[['status', '--pairing-code', 'orca://pair?code=zzzz'], isolated],
[['status', '--environment', 'no-such-environment'], isolated],
[['status', '--environment', 'no-such-environment', '--json'], isolated],
[['worktree', 'list', '--environment', 'no-such-environment', '--json'], isolated],
// The env-var fallback must stay live for non-suppressed commands...
[['status', '--json'], { ...isolated, ORCA_PAIRING_CODE: 'not-a-pairing-code' }],
[['status', '--json'], { ...isolated, ORCA_REMOTE_PAIRING: 'not-a-pairing-code' }],
[['status', '--json'], { ...isolated, ORCA_ENVIRONMENT: 'no-such-environment' }],
// ...and must stay suppressed for the local-only command groups.
//
// NOTE: the only commands that both live in a suppressed group AND touch
// ctx.client are `agent hooks on|off`, which rewrite the user's real agent
// hook configuration in ~/.claude and friends — far outside
// ORCA_USER_DATA_PATH. They are deliberately NOT invoked here. The
// null-vs-undefined suppression they would exercise is covered
// side-effect-free by the constructor-argument assertions in
// src/cli/runtime-client-deferral.test.ts instead.
[['environment', 'list', '--json'], { ...isolated, ORCA_ENVIRONMENT: 'no-such-environment' }],
[['environment', 'list', '--json'], { ...isolated, ORCA_PAIRING_CODE: 'not-a-pairing-code' }],
[['agent-context', '--json'], { ...isolated, ORCA_PAIRING_CODE: 'not-a-pairing-code' }],
// Runtime-unavailable reporting (RuntimeClientError formatting).
[['status'], isolated],
[['status', '--json'], isolated],
[['worktree', 'list', '--json'], isolated],
[['terminal', 'list', '--json'], isolated]
]
// Fuzz: random argv drawn from real command tokens, flags and hostile
// strings. Seeded so a failure is reproducible. Every token here must be
// safe to actually execute — see UNSAFE_TOKENS, which is cross-checked
// against this list before a single case runs.
const tokens = [
'worktree',
'list',
'status',
'browser',
'snapshot',
'terminal',
'environment',
'agent',
'agent-context',
'vm',
'--json',
'--help',
'--pairing-code',
'--environment',
'--worktree',
'orca://pair?code=!!!',
'',
'-',
'--',
'--=',
'a'.repeat(300),
'näme-ünicode',
'💥',
'../..',
'x\ty'
]
let seed = 0x9e3779b9
const next = () => {
seed ^= seed << 13
seed ^= seed >>> 17
seed ^= seed << 5
return (seed >>> 0) / 0x100000000
}
// Why: check the draw POOL, not just the 400 cases it happens to produce.
// `serve` sat in this array for the whole review because the per-case scan
// never named it, and the cases that drew it only survived by accident.
assertTokensSafe(tokens, 'fuzz token pool')
assertFuzzPoolDeclaredReadOnly(tokens)
for (let index = 0; index < 400; index += 1) {
const length = 1 + Math.floor(next() * 4)
const argv = []
for (let part = 0; part < length; part += 1) {
argv.push(tokens[Math.floor(next() * tokens.length)])
}
cases.push([argv, isolated])
}
for (const [argv] of cases) {
assertTokensSafe(argv, `argv ${JSON.stringify(argv)}`)
}
return cases
}
// Why: this script shells out to the REAL CLI with the developer's own HOME and
// PATH, so an argv that reaches the wrong verb does real damage. Two classes:
//
// 1. FOREGROUND — `orca serve` runs Orca until Ctrl+C and `orca open` /
// `claude-teams` spawn processes that outlive the case. A blocking case
// does not fail the run, it stalls it, which is worse than a mismatch.
// 2. MUTATING — writes outside ORCA_USER_DATA_PATH (`agent hooks off` parks
// the real ~/.claude hooks) or drives real browser/desktop input.
//
// Group tokens whose subcommands split read/write (`capture`, `intercept`,
// `label`, `relation`) are denied wholesale: the fuzzer cannot tell them apart.
const FOREGROUND_TOKENS = ['serve', 'open', 'claude-teams', 'exec', 'eval', 'launch', 'attach']
const MUTATING_TOKENS = [
// persistent config and registry state
'on',
'off',
'hooks',
'create',
'remove',
'rm',
'delete',
'add',
'edit',
'set',
'set-value',
'set-base-ref',
'setup-clone',
'setup-create',
'setup-update',
'setup-delete',
'setup-existing-folder',
'install',
'uninstall',
'reinstall',
'update',
'clone',
'apply',
'write',
'reset',
'clear',
'enable',
'disable',
'use-default',
'permissions',
// process and pane lifecycle
'run',
'run-stop',
'start',
'stop',
'kill',
'shutdown',
'close',
'split',
'switch',
'rename',
'focus',
// orchestration writes
'send',
'reply',
'dispatch',
'task-create',
'task-update',
'gate-create',
'gate-resolve',
// issue-tracker writes
'save-issue',
'comment',
'label',
'relation',
'assignee',
'priority',
'estimate',
'due-date',
// browser / computer / emulator input and navigation
'goto',
'back',
'forward',
'reload',
'click',
'dblclick',
'hover',
'fill',
'type',
'type-text',
'select',
'select-all',
'uncheck',
'keypress',
'press-key',
'inserttext',
'hotkey',
'paste-text',
'perform-secondary-action',
'drag',
'scroll',
'scrollintoview',
'wheel',
'move',
'up',
'down',
'tap',
'gesture',
'button',
'rotate',
'upload',
'download',
'dismiss',
'accept',
'highlight',
'viewport',
'geolocation',
'headers',
'credentials',
'offline',
'media',
'device',
'capture',
'intercept'
]
const UNSAFE_TOKENS = new Map([
...FOREGROUND_TOKENS.map((token) => [token, 'runs in the foreground or spawns a process']),
...MUTATING_TOKENS.map((token) => [token, 'can write outside ORCA_USER_DATA_PATH'])
])
// Why: the deny list only catches verbs someone already thought of — `serve`
// sat in the fuzz pool for the whole review because nobody added it. The pool
// is therefore ALSO checked against this allowlist, so a token added to the
// pool fails closed until it is consciously declared read-only here.
const READ_ONLY_FUZZ_TOKENS = new Set([
// command tokens: every path they can form is a list/show or a parse error
'agent',
'agent-context',
'browser',
'environment',
'list',
'snapshot',
'status',
'terminal',
'vm',
'worktree',
// global flags and hostile strings, which reach no handler at all
'--json',
'--help',
'--pairing-code',
'--environment',
'--worktree',
'orca://pair?code=!!!',
'',
'-',
'--',
'--=',
'a'.repeat(300),
'näme-ünicode',
'💥',
'../..',
'x\ty'
])
function assertTokensSafe(tokens, context) {
for (const token of tokens) {
const reason = UNSAFE_TOKENS.get(token)
if (reason) {
throw new Error(`Refusing to run ${context}: "${token}" ${reason}`)
}
}
}
// Why: a token on neither list (or, worse, on both) means the two lists have
// drifted apart. Fail before any case runs rather than sampling and hoping.
function assertFuzzPoolDeclaredReadOnly(tokens) {
for (const token of tokens) {
if (!READ_ONLY_FUZZ_TOKENS.has(token)) {
throw new Error(
`Fuzz token ${JSON.stringify(token)} is not declared in READ_ONLY_FUZZ_TOKENS — declare it read-only or drop it`
)
}
}
for (const token of READ_ONLY_FUZZ_TOKENS) {
if (UNSAFE_TOKENS.has(token)) {
throw new Error(
`Token ${JSON.stringify(token)} is declared both read-only and unsafe — the two lists disagree`
)
}
}
}
const baselineIndex = process.argv.indexOf('--baseline')
const baselineRev = baselineIndex === -1 ? 'HEAD' : process.argv[baselineIndex + 1]
assertMarkersFresh()
const isolatedUserData = mkdtempSync(join(REPO, '.equiv-userdata-'))
mkdirSync(join(isolatedUserData, 'empty'), { recursive: true })
let oldEntry
let newEntry
try {
// Why: build the case list (and run its safety guards) BEFORE the two tsc
// compiles, so an unsafe token fails in a second instead of two minutes in.
const cases = buildCases(isolatedUserData)
console.log(`Building baseline (${baselineRev}) …`)
oldEntry = buildTree('old', baselineRev)
console.log('Building working tree …')
newEntry = buildTree('new', null)
console.log(`Comparing ${cases.length} invocations byte for byte …`)
let mismatches = 0
for (const [argv, env] of cases) {
const before = run(oldEntry, argv, env)
const after = run(newEntry, argv, env)
if (
before.status !== after.status ||
before.stdout !== after.stdout ||
before.stderr !== after.stderr
) {
mismatches += 1
console.error(`\nMISMATCH argv=${JSON.stringify(argv)} env=${JSON.stringify(env)}`)
console.error(` exit before=${before.status} after=${after.status}`)
if (before.stdout !== after.stdout) {
console.error(` stdout before=${JSON.stringify(before.stdout.slice(0, 400))}`)
console.error(` after =${JSON.stringify(after.stdout.slice(0, 400))}`)
}
if (before.stderr !== after.stderr) {
console.error(` stderr before=${JSON.stringify(before.stderr.slice(0, 400))}`)
console.error(` after =${JSON.stringify(after.stderr.slice(0, 400))}`)
}
}
}
if (mismatches > 0) {
throw new Error(`${mismatches} of ${cases.length} invocations differ`)
}
console.log(`\nAll ${cases.length} invocations byte-identical (stdout, stderr, exit code).`)
} finally {
rmSync(isolatedUserData, { recursive: true, force: true })
for (const label of ['old', 'new']) {
rmSync(resolve(REPO, `.equiv-out-${label}`), { recursive: true, force: true })
}
}
@@ -0,0 +1,178 @@
#!/usr/bin/env node
// Benchmark: the per-id worktree/tab lookups in session hydration and terminal reconnect.
//
// Four sites in store/slices/terminals.ts re-flattened worktreesByRepo (or tabsByWorktree)
// and linearly searched it once per loop iteration -- O(rows x ids) for O(rows + ids)
// distinct work. The fix builds one first-wins index per loop.
//
// This runs on the renderer's synchronous cold-start path and gates workspaceSessionReady,
// which blocks terminal pane mounting, so the cost is paid before the first frame.
//
// Both arms produce the resolved rows and are compared for equality before timing, so an
// index that resolved differently could not be reported as a win.
//
// Run with: node config/scripts/hydrate-worktree-lookup-benchmark.mjs
import { performance } from 'node:perf_hooks'
const ITERATIONS = Number(process.env.ORCA_HYDRATE_BENCH_ITERATIONS ?? '60')
const WARMUP = Number(process.env.ORCA_HYDRATE_BENCH_WARMUP ?? '10')
const ROUNDS = 6
for (const [name, value] of [
['ORCA_HYDRATE_BENCH_ITERATIONS', ITERATIONS],
['ORCA_HYDRATE_BENCH_WARMUP', WARMUP]
]) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, received ${value}`)
}
}
// Pre-fix: re-flatten and linear-search per id.
function resolveByFlatten(worktreesByRepo, ids) {
const resolved = []
for (const id of ids) {
const worktree = Object.values(worktreesByRepo)
.flat()
.find((entry) => entry.id === id)
resolved.push(worktree ? worktree.repoId : null)
}
return resolved
}
// Post-fix: mirrors buildWorktreeByIdIndex in store/slices/worktree-by-id-index.ts.
function resolveByIndex(worktreesByRepo, ids) {
const index = new Map()
for (const worktrees of Object.values(worktreesByRepo)) {
for (const worktree of worktrees) {
if (!index.has(worktree.id)) {
index.set(worktree.id, worktree)
}
}
}
const resolved = []
for (const id of ids) {
const worktree = index.get(id)
resolved.push(worktree ? worktree.repoId : null)
}
return resolved
}
function makeStore(repoCount, worktreesPerRepo) {
const worktreesByRepo = {}
for (let repo = 0; repo < repoCount; repo += 1) {
const repoId = `repo-${repo}`
worktreesByRepo[repoId] = Array.from({ length: worktreesPerRepo }, (_value, index) => ({
id: `${repoId}/wt-${index}`,
repoId,
path: `/Users/dev/worktrees/${repoId}/wt-${index}`,
branch: `feature/branch-${index}`
}))
}
// Why a deliberate duplicate: `.find()` is first-wins, so an index that overwrote on
// collision would resolve a different repo. Without a collision in the fixture that
// difference is unobservable and the equality check below would pass a broken index.
if (repoCount > 1) {
const [firstRepo, secondRepo] = Object.keys(worktreesByRepo)
worktreesByRepo[secondRepo] = [
{ ...worktreesByRepo[firstRepo][0], repoId: secondRepo },
...worktreesByRepo[secondRepo]
]
}
return worktreesByRepo
}
// Why a miss fraction: SSH worktrees are absent from worktreesByRepo at cold start, so
// the real workload includes ids that scan the whole list without matching -- the worst
// case for the linear arm, and the one the code comments call out explicitly.
function makeIds(worktreesByRepo, count) {
const all = Object.values(worktreesByRepo).flat()
const ids = Array.from({ length: count }, (_value, index) =>
index % 7 === 0 ? `absent/wt-${index}` : all[(index * 31) % all.length].id
)
// Always look up the duplicated id, so first-wins is exercised, not just present.
ids[1] = all[0].id
return ids
}
function timeArm(resolve, worktreesByRepo, ids) {
let sink = 0
const start = performance.now()
for (let index = 0; index < ITERATIONS; index += 1) {
// Consume the result so V8 cannot drop the call as dead.
sink += resolve(worktreesByRepo, ids).length
}
const elapsed = (performance.now() - start) / ITERATIONS
if (sink === -1) {
throw new Error('unreachable')
}
return elapsed
}
function median(samples) {
const sorted = [...samples].sort((a, b) => a - b)
const mid = sorted.length / 2
return (sorted[mid - 1] + sorted[mid]) / 2
}
// Arms alternate which one leads so within-round drift cannot favour either.
function measure(worktreesByRepo, ids) {
for (let index = 0; index < WARMUP; index += 1) {
resolveByFlatten(worktreesByRepo, ids)
resolveByIndex(worktreesByRepo, ids)
}
const flattenSamples = []
const indexSamples = []
for (let round = 0; round < ROUNDS; round += 1) {
if (round % 2 === 0) {
flattenSamples.push(timeArm(resolveByFlatten, worktreesByRepo, ids))
indexSamples.push(timeArm(resolveByIndex, worktreesByRepo, ids))
} else {
indexSamples.push(timeArm(resolveByIndex, worktreesByRepo, ids))
flattenSamples.push(timeArm(resolveByFlatten, worktreesByRepo, ids))
}
}
return { flattenMs: median(flattenSamples), indexMs: median(indexSamples) }
}
const pad = (value, width) => String(value).padStart(width)
console.log('Session-hydration worktree lookup, per cold start. Lower is better.')
console.log(`iterations=${ITERATIONS} warmup=${WARMUP} rounds=${ROUNDS} (per-arm medians)`)
console.log(
`${pad('repos', 6)} ${pad('worktrees', 10)} ${pad('ids', 5)} ${pad('flatten', 11)} ${pad('indexed', 11)} ${pad('speedup', 9)}`
)
// Row shapes are synthetic, sized against a real orca-data.json on a heavy machine
// (10 repos / 423 worktrees / 188 pending reconnect). They are not that dataset: the
// generator spreads worktrees evenly and injects one duplicate id, so treat the counts
// as "about this scale", not a replay.
for (const [repoCount, worktreesPerRepo, idCount] of [
[1, 5, 5],
[3, 20, 20],
[10, 42, 188],
[10, 100, 400]
]) {
const worktreesByRepo = makeStore(repoCount, worktreesPerRepo)
const ids = makeIds(worktreesByRepo, idCount)
const flattenResult = resolveByFlatten(worktreesByRepo, ids)
const indexResult = resolveByIndex(worktreesByRepo, ids)
if (JSON.stringify(flattenResult) !== JSON.stringify(indexResult)) {
throw new Error(`resolution differs at ${repoCount} repos x ${worktreesPerRepo} worktrees`)
}
if (!flattenResult.some((value) => value !== null)) {
throw new Error(`fixture resolved nothing at ${repoCount} repos`)
}
if (!flattenResult.some((value) => value === null)) {
throw new Error(`fixture had no absent ids at ${repoCount} repos`)
}
// Count the generated rows rather than multiplying: makeStore injects a duplicate
// id for multi-repo cases, so the product would misreport the fixture by one.
const worktreeCount = Object.values(worktreesByRepo).reduce((sum, rows) => sum + rows.length, 0)
const { flattenMs, indexMs } = measure(worktreesByRepo, ids)
console.log(
`${pad(repoCount, 6)} ${pad(worktreeCount, 10)} ${pad(idCount, 5)} ${pad(`${flattenMs.toFixed(4)} ms`, 11)} ${pad(`${indexMs.toFixed(4)} ms`, 11)} ${pad(`${(flattenMs / indexMs).toFixed(1)}x`, 9)}`
)
}
console.log(
'\nFixtures are synthetic at real-world scale, not a replay of a real session.\nThis times one of the four lookup sites. A one-repo session sees almost nothing;\nthe win scales with worktrees x pending ids, and lands on the cold-start path that\ngates terminal pane mounting.'
)
@@ -0,0 +1,202 @@
#!/usr/bin/env node
// Measures the complete ProseMirror doc traversal used by the two doc-link plugins.
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { performance } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'
import { Schema } from '@tiptap/pm/model'
import {
canHoldDocLink,
DOC_LINK_PATTERN,
isDocLinkLiteralCodeTextNode
} from '../../src/renderer/src/components/editor/rich-markdown-doc-link-scan.ts'
const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url))
const ITERATIONS = Number(process.env.ORCA_DOC_LINK_BENCH_ITERATIONS ?? '41')
const WARMUP_ITERATIONS = Math.min(9, ITERATIONS)
if (!Number.isSafeInteger(ITERATIONS) || ITERATIONS <= 0) {
throw new Error(`ORCA_DOC_LINK_BENCH_ITERATIONS must be a positive integer, got ${ITERATIONS}`)
}
const schema = new Schema({
nodes: {
doc: { content: 'paragraph+' },
paragraph: { content: 'text*' },
text: { group: 'inline' }
}
})
function walkUngated(doc) {
let matches = 0
let visited = 0
doc.descendants((node, _pos, parent) => {
visited += 1
if (node.type.name !== 'text' || !node.text || isDocLinkLiteralCodeTextNode(node, parent)) {
return
}
for (const _match of node.text.matchAll(DOC_LINK_PATTERN)) {
matches += 1
}
})
return { matches, visited }
}
function walkGated(doc) {
let matches = 0
let visited = 0
doc.descendants((node, _pos, parent) => {
visited += 1
if (!canHoldDocLink(node, parent)) {
return
}
for (const _match of node.text.matchAll(DOC_LINK_PATTERN)) {
matches += 1
}
})
return { matches, visited }
}
function countMatches(source) {
let matches = 0
for (const _match of source.matchAll(DOC_LINK_PATTERN)) {
matches += 1
}
return matches
}
function loadDocs() {
const files = execFileSync('git', ['ls-files', '*.md', 'docs/*.md'], {
cwd: REPO_ROOT,
maxBuffer: 256 * 1024 * 1024
})
.toString()
.split('\n')
.filter(Boolean)
const docs = []
for (const file of files) {
try {
const source = readFileSync(join(REPO_ROOT, file), 'utf8')
const lines = source.split('\n').filter(Boolean)
if (lines.length > 0) {
docs.push({ file, lines, size: source.length, matches: countMatches(source) })
}
} catch {
// Indexed paths can disappear while the benchmark is running.
}
}
return docs
}
function createFixture(doc, nonce) {
const paragraphs = doc.lines.map((line, index) => {
const text = index === 0 ? `${line} bench-${nonce}` : line
return schema.node('paragraph', null, text ? schema.text(text) : undefined)
})
return schema.node('doc', null, paragraphs)
}
function median(samples) {
const sorted = [...samples].sort((a, b) => a - b)
return sorted[Math.floor(sorted.length / 2)]
}
function measureCorpus(docs) {
const samples = { ungated: [], gated: [] }
const totals = {
ungated: { matches: 0, visited: 0 },
gated: { matches: 0, visited: 0 }
}
const seenFixtures = new WeakSet()
let expectedMatches = 0
let expectedVisited = 0
const measuredOrder = []
for (let round = -WARMUP_ITERATIONS; round < ITERATIONS; round += 1) {
const doc = docs[(round + WARMUP_ITERATIONS) % docs.length]
const measured = round >= 0
const order =
(round + WARMUP_ITERATIONS) % 2 === 0 ? ['ungated', 'gated'] : ['gated', 'ungated']
const fixtures = {
ungated: createFixture(doc, String(round)),
gated: createFixture(doc, String(round))
}
for (const arm of order) {
const fixture = fixtures[arm]
if (seenFixtures.has(fixture)) {
throw new Error('timed fixture was reused')
}
seenFixtures.add(fixture)
const start = performance.now()
const result = arm === 'ungated' ? walkUngated(fixture) : walkGated(fixture)
const elapsed = performance.now() - start
if (measured) {
samples[arm].push(elapsed)
totals[arm].matches += result.matches
totals[arm].visited += result.visited
measuredOrder.push(arm)
}
}
if (measured) {
expectedMatches += doc.matches
expectedVisited += doc.lines.length * 2
}
}
if (totals.ungated.matches !== expectedMatches || totals.gated.matches !== expectedMatches) {
throw new Error(
`gate changed matches: expected ${expectedMatches}, ungated ${totals.ungated.matches}, gated ${totals.gated.matches}`
)
}
if (totals.ungated.visited !== expectedVisited || totals.gated.visited !== expectedVisited) {
throw new Error(
`full traversal result was not consumed: expected ${expectedVisited}, ungated ${totals.ungated.visited}, gated ${totals.gated.visited}`
)
}
for (let index = 0; index < measuredOrder.length; index += 2) {
const pair = measuredOrder.slice(index, index + 2).join(',')
const previousPair = index === 0 ? null : measuredOrder.slice(index - 2, index).join(',')
if (!['ungated,gated', 'gated,ungated'].includes(pair) || pair === previousPair) {
throw new Error('benchmark arms were not interleaved in alternating order')
}
}
return { ungated: median(samples.ungated), gated: median(samples.gated) }
}
const docs = loadDocs()
if (docs.length === 0) {
throw new Error('no markdown files found in the index')
}
const large = docs.filter((doc) => doc.size > 3000)
const biggest = docs.reduce((a, b) => (b.size > a.size ? b : a))
const pad = (value, width) => String(value).padStart(width)
console.log('Doc-link ProseMirror traversal, per editor transaction. Lower is better.')
console.log(
`docs=${docs.length} (>3KB: ${large.length}) iterations=${ITERATIONS} (interleaved median)`
)
console.log(
`${pad('corpus', 26)} ${pad('ungated', 11)} ${pad('gated', 11)} ${pad('delta', 11)} ${pad('speedup', 9)}`
)
for (const [label, set] of [
['all repo markdown', docs],
['docs over 3 KB', large],
[`biggest (${biggest.file.split('/').pop()})`, [biggest]]
]) {
if (set.length === 0) {
console.log(`${pad(label, 26)} ${pad('no docs in this corpus — skipped', 44)}`)
continue
}
const { ungated, gated } = measureCorpus(set)
const delta = ungated - gated
console.log(
`${pad(label, 26)} ${pad(`${(ungated * 1000).toFixed(1)} us`, 11)} ${pad(`${(gated * 1000).toFixed(1)} us`, 11)} ${pad(`${(delta * 1000).toFixed(1)} us`, 11)} ${pad(`${(ungated / gated).toFixed(2)}x`, 9)}`
)
}
console.log(
'\nAuto-conversion pays this once per keystroke; preview decorations pay it once\nper keystroke and once per caret move.'
)
@@ -0,0 +1,2 @@
export const BUILD_IDENTITY_RE = /\b(?:const|let|var)\s+BUILD_IDENTITY\s*=\s*"(rc|stable)"/
export const WRITE_KEY_RE = /\b(?:const|let|var)\s+WRITE_KEY\s*=\s*"(phc_[A-Za-z0-9_-]+)"/
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { BUILD_IDENTITY_RE, WRITE_KEY_RE } from './telemetry-bundle-constant-patterns.mjs'
describe('telemetry bundle constant patterns', () => {
it.each(['const', 'let', 'var'])('accepts %s declarations', (declaration) => {
expect(`${declaration} BUILD_IDENTITY = "rc"`).toMatch(BUILD_IDENTITY_RE)
expect(`${declaration} WRITE_KEY = "phc_example-key_123"`).toMatch(WRITE_KEY_RE)
})
it('rejects assignments and invalid values', () => {
expect('BUILD_IDENTITY = "rc"').not.toMatch(BUILD_IDENTITY_RE)
expect('const BUILD_IDENTITY = "dev"').not.toMatch(BUILD_IDENTITY_RE)
expect('const WRITE_KEY = null').not.toMatch(WRITE_KEY_RE)
expect('const WRITE_KEY = "example-key"').not.toMatch(WRITE_KEY_RE)
})
})
@@ -0,0 +1,347 @@
#!/usr/bin/env node
// Benchmark: iterateTerminalOutputFrameChunks, which every byte of remote terminal
// output passes through on its way to a mobile/remote-desktop multiplex stream.
//
// The pre-fix loop was `for (const part of data)`: V8 materializes a fresh 1-2 code
// unit string per code point, then measureClipboardTextByteLength re-walked that
// string through codePointAt to get its UTF-8 width, and the chunk was rebuilt with
// `chunk += part`. The gate in front of it (terminalStreamByteLengthExceeds) ran the
// same per-code-point walk over the whole payload a second time.
//
// The fix: one charCodeAt scan computing the UTF-8 width inline with no per-code-point
// string, chunk text taken as data.slice(chunkStart, end), and a gate of
// `data.length > CHUNK || Buffer.byteLength(data, 'utf8') > CHUNK` -- sound because a
// string's UTF-8 length is never below its UTF-16 length.
//
// BOTH arms run the complete production path, encodeTerminalStreamText included, and
// their emitted frames (base64 + seq + opcode) are compared before any timing, so an
// arm that split differently or renumbered a seq cannot be reported as a win.
import { spawnSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import nodeModule from 'node:module'
import { performance } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'
// terminal-stream-protocol.ts declares a TS enum, which Node's default strip-only
// loader rejects; re-exec once with type transformation rather than re-modelling
// the opcodes here (a hand copy could drift from the wire contract).
if (!process.execArgv.includes('--experimental-transform-types')) {
const result = spawnSync(
process.execPath,
['--experimental-transform-types', '--no-warnings', import.meta.filename],
{ stdio: 'inherit' }
)
process.exit(result.status ?? 1)
}
// The app's TS sources import siblings without an extension; Node's ESM resolver needs it.
nodeModule.registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier.startsWith('.') && !/\.[cm]?[jt]s$/.test(specifier) && context.parentURL) {
const candidate = new URL(`${specifier}.ts`, context.parentURL)
if (existsSync(fileURLToPath(candidate))) {
return { url: candidate.href, shortCircuit: true }
}
}
return nextResolve(specifier, context)
}
})
const ITERATIONS = Number(process.env.ORCA_FRAME_CHUNK_BENCH_ITERATIONS ?? '40')
const WARMUP = Number(process.env.ORCA_FRAME_CHUNK_BENCH_WARMUP ?? '8')
const ROUNDS = Number(process.env.ORCA_FRAME_CHUNK_BENCH_ROUNDS ?? '6')
for (const [name, value] of [
['ORCA_FRAME_CHUNK_BENCH_ITERATIONS', ITERATIONS],
['ORCA_FRAME_CHUNK_BENCH_WARMUP', WARMUP],
['ORCA_FRAME_CHUNK_BENCH_ROUNDS', ROUNDS]
]) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, received ${value}`)
}
}
if (ROUNDS % 2 !== 0) {
throw new Error(`ORCA_FRAME_CHUNK_BENCH_ROUNDS must be even so each arm leads equally`)
}
const CHUNK_SOURCE = readFileSync(
new URL('../../src/main/runtime/rpc/terminal-output-frame-chunks.ts', import.meta.url),
'utf8'
)
const CLIPBOARD_SOURCE = readFileSync(
new URL('../../src/shared/clipboard-text.ts', import.meta.url),
'utf8'
)
// Why re-read the sources: this benchmark's claim is that the OLD arm paid a
// measureClipboardTextByteLength call per code point and the NEW one pays a single
// Buffer.byteLength gate. Match the CALL form, not a bare word -- a comment naming
// the function would satisfy a substring check.
for (const [source, label, marker] of [
[CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', "Buffer.byteLength(data, 'utf8')"],
[CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', 'const text = data.slice(chunkStart, end)'],
[CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', 'data.charCodeAt(index + 1)'],
[CLIPBOARD_SOURCE, 'clipboard-text.ts', 'export function measureClipboardTextByteLength('],
[CLIPBOARD_SOURCE, 'clipboard-text.ts', 'text.codePointAt(index)']
]) {
if (!source.includes(marker)) {
throw new Error(`${label} no longer contains \`${marker}\`; this benchmark is stale`)
}
}
if (CHUNK_SOURCE.includes('for (const part of data)')) {
throw new Error(
'terminal-output-frame-chunks.ts still iterates code points as strings; this benchmark is stale'
)
}
const { TERMINAL_STREAM_CHUNK_BYTES } = await import(
new URL('../../src/shared/terminal-multiplex-flow-control.ts', import.meta.url).href
)
const { measureClipboardTextByteLength } = await import(
new URL('../../src/shared/clipboard-text.ts', import.meta.url).href
)
const { TerminalStreamOpcode, encodeTerminalStreamJson, encodeTerminalStreamText } = await import(
new URL('../../src/shared/terminal-stream-protocol.ts', import.meta.url).href
)
const { iterateTerminalOutputFrameChunks } = await import(
new URL('../../src/main/runtime/rpc/terminal-output-frame-chunks.ts', import.meta.url).href
)
// Pre-fix arm: the exact code that shipped, including the second full walk in the gate.
function* iterateBefore(data, meta) {
const rawLength = meta?.rawLength ?? data.length
if (meta?.transformed || rawLength !== data.length) {
yield {
opcode: TerminalStreamOpcode.OutputSpan,
bytes: encodeTerminalStreamJson({ data, rawLength, transformed: true }),
seq: meta?.seq
}
return
}
if (
!measureClipboardTextByteLength(data, { stopAfterBytes: TERMINAL_STREAM_CHUNK_BYTES })
.exceededLimit
) {
yield { bytes: encodeTerminalStreamText(data), seq: meta?.seq }
return
}
const canPreserveChunkSeq = typeof meta?.seq === 'number' && rawLength === data.length
const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta?.seq === 'number'
const startSeq = canPreserveChunkSeq ? meta.seq - rawLength : undefined
let chunk = ''
let chunkBytes = 0
let chunkStartOffset = 0
let offset = 0
let delayedChunk = null
const takeChunk = () => {
if (!chunk) {
return null
}
const chunkSeq = canPreserveChunkSeq ? startSeq + chunkStartOffset + chunk.length : undefined
const current = { text: chunk, seq: chunkSeq }
chunk = ''
chunkBytes = 0
chunkStartOffset = offset
return current
}
for (const part of data) {
const partBytes = measureClipboardTextByteLength(part).byteLength
if (chunkBytes > 0 && chunkBytes + partBytes > TERMINAL_STREAM_CHUNK_BYTES) {
const nextChunk = takeChunk()
if (nextChunk) {
if (shouldDelayFinalSeq) {
if (delayedChunk) {
yield { bytes: encodeTerminalStreamText(delayedChunk.text) }
}
delayedChunk = nextChunk
} else {
yield { bytes: encodeTerminalStreamText(nextChunk.text), seq: nextChunk.seq }
}
}
}
chunk += part
chunkBytes += partBytes
offset += part.length
}
const finalChunk = takeChunk()
if (shouldDelayFinalSeq) {
if (finalChunk) {
if (delayedChunk) {
yield { bytes: encodeTerminalStreamText(delayedChunk.text) }
}
delayedChunk = finalChunk
}
if (delayedChunk) {
yield { bytes: encodeTerminalStreamText(delayedChunk.text), seq: meta.seq }
}
return
}
if (finalChunk) {
yield { bytes: encodeTerminalStreamText(finalChunk.text), seq: finalChunk.seq }
}
}
// The multiplex stream consumes every frame's bytes/seq/opcode; charge both arms for it.
let frameChecksum = 0
function drain(iterate, data, meta) {
let frames = 0
let bytes = 0
let seqSum = 0
for (const frame of iterate(data, meta)) {
frames += 1
bytes += frame.bytes.byteLength
seqSum += frame.seq ?? 0
}
frameChecksum = Math.imul(frameChecksum ^ (frames + bytes + seqSum), 16777619) >>> 0
return frames
}
function describeFrames(iterate, data, meta) {
const shapes = []
for (const frame of iterate(data, meta)) {
shapes.push(
`${Buffer.from(frame.bytes).toString('base64')}|${frame.seq ?? 'u'}|${frame.opcode ?? 'u'}`
)
}
return shapes.join('\n')
}
const SURROGATE_PAIR = '\u{1f600}'
const LONE_HIGH = '\ud83d'
function repeatTo(unit, codeUnits) {
let out = ''
while (out.length < codeUnits) {
out += unit
}
return out.slice(0, out.length - (out.length % unit.length))
}
// A realistic agent-TUI line: SGR runs, a wide glyph, a currency sign, an emoji.
const TUI_LINE =
'\u001b[35m\u273b Thinking\u001b[0m about the \u20ac plan \u{1f600} 42 passed, 0 failed\r\n'
const fixtures = [
{
label: 'ascii 4KiB (typical batch)',
data: 'x'.repeat(4 * 1024),
meta: (data) => ({ seq: 5_000_000, rawLength: data.length })
},
{
label: `ascii ${TERMINAL_STREAM_CHUNK_BYTES}B (at cap)`,
data: 'x'.repeat(TERMINAL_STREAM_CHUNK_BYTES),
meta: (data) => ({ seq: 5_000_000, rawLength: data.length })
},
{
label: 'ascii 64KiB (batch cap, 2 chunks)',
data: 'x'.repeat(64 * 1024),
meta: (data) => ({ seq: 5_000_000, rawLength: data.length })
},
{
label: 'mixed TUI 64KiB (2 chunks)',
data: repeatTo(TUI_LINE, 64 * 1024),
meta: (data) => ({ seq: 5_000_000, rawLength: data.length })
},
{
// seq without an explicit rawLength: the batcher's ordinary shape.
label: 'mixed TUI 64KiB (implicit raw)',
data: repeatTo(TUI_LINE, 64 * 1024),
meta: () => ({ seq: 5_000_000 })
},
{
label: 'emoji 64KiB (4-byte, 2 chunks)',
data: repeatTo(SURROGATE_PAIR, 32 * 1024),
meta: (data) => ({ seq: 5_000_000, rawLength: data.length })
},
{
label: 'lone surrogates 64KiB',
data: repeatTo(LONE_HIGH, 64 * 1024),
meta: (data) => ({ seq: 5_000_000, rawLength: data.length })
},
{
label: 'ascii 512KiB (snapshot chunking)',
data: 'x'.repeat(512 * 1024),
meta: () => undefined
}
]
function median(samples) {
const sorted = [...samples].sort((a, b) => a - b)
const middle = Math.floor(sorted.length / 2)
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
}
// Why interleaved with an alternating lead: running one arm's whole batch first lets
// CPU frequency drift correlate with whichever arm is being measured. Elsewhere in this
// effort that alone reported 23.3x for a real 6.7x.
function measureInterleaved(data, meta) {
for (let index = 0; index < WARMUP; index += 1) {
drain(iterateBefore, data, meta)
drain(iterateTerminalOutputFrameChunks, data, meta)
}
const beforeSamples = []
const afterSamples = []
for (let round = 0; round < ROUNDS; round += 1) {
const runBefore = () => {
const start = performance.now()
for (let index = 0; index < ITERATIONS; index += 1) {
drain(iterateBefore, data, meta)
}
beforeSamples.push((performance.now() - start) / ITERATIONS)
}
const runAfter = () => {
const start = performance.now()
for (let index = 0; index < ITERATIONS; index += 1) {
drain(iterateTerminalOutputFrameChunks, data, meta)
}
afterSamples.push((performance.now() - start) / ITERATIONS)
}
if (round % 2 === 0) {
runBefore()
runAfter()
} else {
runAfter()
runBefore()
}
}
return { beforeMs: median(beforeSamples), afterMs: median(afterSamples) }
}
const pad = (value, width) => String(value).padStart(width)
console.log('iterateTerminalOutputFrameChunks, per flushed batch. Lower is better.')
console.log(
`iterations=${ITERATIONS} warmup=${WARMUP} rounds=${ROUNDS} (alternating lead, per-arm median)`
)
console.log(
`${pad('fixture', 34)} ${pad('frames', 7)} ${pad('per-part', 11)} ${pad('scanned', 11)} ${pad('speedup', 9)}`
)
let comparedFixtures = 0
for (const fixture of fixtures) {
const meta = fixture.meta(fixture.data)
const before = describeFrames(iterateBefore, fixture.data, meta)
const after = describeFrames(iterateTerminalOutputFrameChunks, fixture.data, meta)
if (before !== after) {
throw new Error(`frames differ for ${fixture.label}`)
}
const frames = before.split('\n').length
// Guard against a fixture that never reaches the chunking loop; then both arms would
// just be measuring the gate and the comparison above would prove nothing about it.
if (fixture.data.length > TERMINAL_STREAM_CHUNK_BYTES && frames < 2) {
throw new Error(`${fixture.label} never split; fixture does not exercise the chunk loop`)
}
comparedFixtures += 1
const { beforeMs, afterMs } = measureInterleaved(fixture.data, meta)
console.log(
`${pad(fixture.label, 34)} ${pad(frames, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}`
)
}
console.log(
`\nvalidated=${comparedFixtures} fixtures frame-identical before timing, checksum=${frameChecksum >>> 0}`
)
console.log(
'Every byte of remote terminal output crosses this function; the batcher flushes at\nTERMINAL_OUTPUT_BATCH_MAX_BYTES (64 KiB) or every 5 ms, so a busy remote agent pane\nruns it tens of times a second per subscribed stream.'
)
@@ -0,0 +1,416 @@
#!/usr/bin/env node
// Benchmarks the production terminal byte-measurement exports at their real budgets: the output
// batcher push and the snapshot budget scan. Every scenario asserts whether production invoked
// Buffer.byteLength, so implementation drift cannot preserve stale speedup claims.
import { spawnSync } from 'node:child_process'
import { performance } from 'node:perf_hooks'
import fs from 'node:fs'
import nodeModule from 'node:module'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
if (!process.execArgv.includes('--experimental-transform-types')) {
const result = spawnSync(
process.execPath,
['--experimental-transform-types', '--no-warnings', import.meta.filename],
{ stdio: 'inherit' }
)
process.exit(result.status ?? 1)
}
// The app's TS sources import siblings without an extension; Node's ESM resolver needs it.
nodeModule.registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier.startsWith('.') && !/\.[cm]?[jt]s$/.test(specifier) && context.parentURL) {
const candidate = new URL(`${specifier}.ts`, context.parentURL)
if (fs.existsSync(fileURLToPath(candidate))) {
return { url: candidate.href, shortCircuit: true }
}
}
return nextResolve(specifier, context)
}
})
const ROOT = path.resolve(import.meta.dirname, '../..')
const ITERATIONS = Number(process.env.ORCA_BYTE_LENGTH_BENCH_ITERATIONS ?? '61')
let resultChecksum = 0
let validatedPairs = 0
if (!Number.isSafeInteger(ITERATIONS) || ITERATIONS <= 0) {
throw new Error(`ORCA_BYTE_LENGTH_BENCH_ITERATIONS must be a positive integer, got ${ITERATIONS}`)
}
function readSource(relative) {
return fs.readFileSync(path.join(ROOT, relative), 'utf8')
}
const TERMINAL_SOURCE = readSource('src/main/runtime/rpc/methods/terminal.ts')
function requireCallForm(source, needle, label) {
if (!source.includes(needle)) {
throw new Error(`${label} is stale: expected call form \`${needle}\` was not found`)
}
}
const { TERMINAL_OUTPUT_BATCH_MAX_BYTES, TERMINAL_STREAM_CHUNK_BYTES } = await import(
new URL('../../src/shared/terminal-multiplex-flow-control.ts', import.meta.url).href
)
const { measureClipboardTextByteLength } = await import(
new URL('../../src/shared/clipboard-text.ts', import.meta.url).href
)
const {
MIN_NATIVE_BYTE_LENGTH_CODE_UNITS,
measureTerminalStreamByteLength,
terminalStreamByteLengthExceeds
} = await import(
new URL('../../src/main/runtime/rpc/terminal-stream-byte-length.ts', import.meta.url).href
)
const REQUESTED_SNAPSHOT_BYTE_BUDGET = (() => {
const match = /const REQUESTED_SNAPSHOT_BYTE_BUDGET = ([^\n]+)/.exec(TERMINAL_SOURCE)
if (!match) {
throw new Error('terminal.ts is stale: REQUESTED_SNAPSHOT_BYTE_BUDGET is gone')
}
return Number(new Function(`return (${match[1].trim()})`)())
})()
requireCallForm(TERMINAL_SOURCE, 'measureTerminalStreamByteLength(data, {', 'terminal.ts')
requireCallForm(TERMINAL_SOURCE, 'stopAfterBytes: remainingBudget', 'terminal.ts')
requireCallForm(
TERMINAL_SOURCE,
'terminalStreamByteLengthExceeds(data, REQUESTED_SNAPSHOT_BYTE_BUDGET)',
'terminal.ts'
)
const nativeByteLength = Buffer.byteLength
function runWithNativeCallCount(fn) {
let calls = 0
Buffer.byteLength = (...args) => {
calls += 1
return Reflect.apply(nativeByteLength, Buffer, args)
}
try {
return { output: fn(), calls }
} finally {
Buffer.byteLength = nativeByteLength
}
}
// ---- OLD ARM: the production implementation terminal.ts called before this change.
const legacyMeasure = measureClipboardTextByteLength
const legacyExceeds = (data, maxBytes) =>
measureClipboardTextByteLength(data, { stopAfterBytes: maxBytes }).exceededLimit
// ---- Fixtures. Deterministic, seeded, and varied per sample so V8 cannot hoist.
function mulberry32(seed) {
let state = seed >>> 0
return () => {
state = (state + 0x6d2b79f5) >>> 0
let t = state
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
// Realistic agent-TUI output: mostly ASCII with SGR runs, box drawing, and emoji status
// glyphs, plus a per-sample marker so no two measured strings are identical.
function makeTerminalText(codeUnits, sampleId) {
const random = mulberry32(sampleId * 2654435761)
const lines = [
'✻ Thinking…\r\n',
' ⏺ Running tests… 42 passed, 0 failed\r\n',
'│ src/main/runtime/rpc/methods/terminal.ts │\r\n',
' ✅ build succeeded in 12.4s — café naïve\r\n',
'+ added line\r\n'
]
let text = `sample:${sampleId}\r\n`
while (text.length < codeUnits) {
text += lines[Math.floor(random() * lines.length)]
}
return text.slice(0, codeUnits)
}
// Keystroke echo and tiny interactive writes: the shapes a PTY emits between key presses.
function makeInteractiveText(codeUnits, sampleId) {
const random = mulberry32(sampleId * 40503)
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789 ./-_'
let text = ''
while (text.length < codeUnits) {
text += alphabet[Math.floor(random() * alphabet.length)]
}
return text.slice(0, codeUnits)
}
// Trim to just under a BYTE budget so the legacy arm runs its full scan without tripping the limit.
function makeTerminalTextUnderBytes(byteBudget, sampleId) {
let text = makeTerminalText(byteBudget, sampleId)
while (Buffer.byteLength(text, 'utf8') > byteBudget) {
text = text.slice(0, Math.floor(text.length * (byteBudget / Buffer.byteLength(text, 'utf8'))))
}
return text
}
// The TRUE adversary for the exceeds gate: stay at the code-unit cap so `length > maxBytes`
// cannot short-circuit, but pack 3-byte BMP scalars so the legacy scan bails out after only a
// THIRD of the string while Buffer.byteLength still walks all of it.
function makeEarlyTripText(byteBudget, sampleId) {
const tripUnits = Math.ceil((byteBudget + 1) / 3)
const marker = String.fromCharCode(0x4e00 + (sampleId % 4096))
const prefix = `${marker}${'走'.repeat(tripUnits - 1)}`
return `${prefix}${'a'.repeat(byteBudget - tripUnits)}`
}
function median(samples) {
const sorted = [...samples].sort((a, b) => a - b)
const middle = Math.floor(sorted.length / 2)
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
}
function consume(value) {
resultChecksum = Math.imul(resultChecksum ^ (value | 0), 16777619) >>> 0
}
// Small inputs are far below timer resolution, so batch them: build `repeats` distinct
// samples, time the whole loop, and report per-call cost. Consuming the running total
// inside the timed region keeps V8 from hoisting the calls out.
function runScenario(scenario) {
const repeats = scenario.repeats ?? 1
const samples = { legacy: [], next: [] }
const runArm = (fn, inputs) => {
const start = performance.now()
let total = 0
for (const input of inputs) {
total += scenario.checksum(fn(input))
}
const elapsed = performance.now() - start
return { elapsed, total }
}
for (let index = 0; index < ITERATIONS; index += 1) {
// Alternate which arm leads on every iteration so cache/JIT warmup is shared evenly.
for (const legacyFirst of index % 2 === 0 ? [true, false] : [false, true]) {
const batch = index * 2 + (legacyFirst ? 0 : 1)
const inputs = []
for (let repeat = 0; repeat < repeats; repeat += 1) {
inputs.push(scenario.make(batch * repeats + repeat))
}
const legacyOutputs = inputs.map(scenario.legacy)
const observed = runWithNativeCallCount(() => inputs.map(scenario.next))
for (let inputIndex = 0; inputIndex < inputs.length; inputIndex += 1) {
const input = inputs[inputIndex]
const legacyOutput = legacyOutputs[inputIndex]
const nextOutput = observed.output[inputIndex]
if (!scenario.equal(legacyOutput, nextOutput)) {
throw new Error(
`${scenario.label}: arms disagree on ${JSON.stringify(input.slice(0, 40))}`
)
}
scenario.assertResult(legacyOutput, input)
validatedPairs += 1
}
scenario.assertNativeCalls(inputs.length, observed.calls)
let legacyResult
let nextResult
if (legacyFirst) {
legacyResult = runArm(scenario.legacy, inputs)
nextResult = runArm(scenario.next, inputs)
} else {
nextResult = runArm(scenario.next, inputs)
legacyResult = runArm(scenario.legacy, inputs)
}
consume(legacyResult.total)
consume(nextResult.total)
samples.legacy.push(legacyResult.elapsed / repeats)
samples.next.push(nextResult.elapsed / repeats)
}
}
return { legacy: median(samples.legacy), next: median(samples.next) }
}
const measurementEqual = (a, b) =>
a.byteLength === b.byteLength && a.exceededLimit === b.exceededLimit
const measurementChecksum = (m) => m.byteLength + (m.exceededLimit ? 1 : 0)
const booleanChecksum = (value) => (value ? 1 : 0)
// Every scenario states which production branch it expects. Native fixtures require exactly one
// Buffer.byteLength call per input; fallback fixtures require none.
function requireBranch(expected) {
return (inputCount, calls) => {
const expectedCalls = expected === 'nativeFastPath' ? inputCount : 0
if (calls !== expectedCalls) {
throw new Error(
`expected the production ${expected} branch (${expectedCalls} Buffer.byteLength calls), got ${calls}`
)
}
}
}
const batchScenario = (label, make, options = {}) => ({
label,
make,
repeats: options.repeats,
legacy: (input) => legacyMeasure(input, { stopAfterBytes: TERMINAL_OUTPUT_BATCH_MAX_BYTES }),
next: (input) =>
measureTerminalStreamByteLength(input, {
stopAfterBytes: TERMINAL_OUTPUT_BATCH_MAX_BYTES
}),
equal: measurementEqual,
checksum: measurementChecksum,
assertResult: (out, input) => options.assert?.(out, input),
assertNativeCalls: requireBranch(options.branch)
})
const gateScenario = (label, budget, make, options = {}) => ({
label,
make,
repeats: options.repeats,
legacy: (input) => legacyExceeds(input, budget),
next: (input) => terminalStreamByteLengthExceeds(input, budget),
equal: (a, b) => a === b,
checksum: booleanChecksum,
assertResult: (out, input) => options.assert?.(out, input),
assertNativeCalls: requireBranch(options.branch)
})
const scenarios = [
batchScenario('batcher push 8KiB', (sampleId) => makeTerminalText(8 * 1024, sampleId), {
branch: 'nativeFastPath',
assert: (out) => {
if (out.exceededLimit) {
throw new Error('batcher push 8KiB should stay under the batch budget')
}
}
}),
batchScenario(
'batcher push over budget',
// Oversized on purpose: this is the case where the arms MUST both return the partial count.
(sampleId) => makeTerminalText(3 * TERMINAL_OUTPUT_BATCH_MAX_BYTES, sampleId),
{
branch: 'scanFallback',
assert: (out) => {
if (!out.exceededLimit) {
throw new Error('over-budget fixture never exceeded the limit')
}
}
}
),
// TRUE WORST CASE for the batcher push. The guard only takes the native count when
// length*3 <= stopAfterBytes, which PROVES the limit cannot trip, so the fast path can never
// pay for both a Buffer.byteLength and a scan. What is left is the shape where the native
// call replaces the fewest scan iterations: a chunk sitting just above the code-unit floor.
batchScenario(
`batcher push ${MIN_NATIVE_BYTE_LENGTH_CODE_UNITS}B (floor)`,
(sampleId) => makeInteractiveText(MIN_NATIVE_BYTE_LENGTH_CODE_UNITS, sampleId),
{ branch: 'nativeFastPath', repeats: 4096 }
),
// Below the floor the new arm deliberately keeps the scan, so it is the legacy code exactly.
batchScenario('batcher push 4B keystroke', (sampleId) => makeInteractiveText(4, sampleId), {
branch: 'scanFallback',
repeats: 4096
}),
gateScenario(
`snapshot scan ${(REQUESTED_SNAPSHOT_BYTE_BUDGET / (1024 * 1024)).toFixed(0)}MiB`,
REQUESTED_SNAPSHOT_BYTE_BUDGET,
(sampleId) => makeTerminalTextUnderBytes(REQUESTED_SNAPSHOT_BYTE_BUDGET, sampleId),
{
branch: 'nativeFastPath',
assert: (out) => {
if (out) {
throw new Error('snapshot fixture should sit under the budget so the full scan runs')
}
}
}
),
// TRUE WORST CASE for the boolean gate: at the code-unit cap so `length > maxBytes` cannot
// short-circuit, but 3-byte scalars let the legacy scan bail out a THIRD of the way in while
// Buffer.byteLength still walks the whole string. This is where the new arm can actually lose.
gateScenario(
'snapshot gate early-trip',
REQUESTED_SNAPSHOT_BYTE_BUDGET,
(sampleId) => makeEarlyTripText(REQUESTED_SNAPSHOT_BYTE_BUDGET, sampleId),
{
branch: 'nativeFastPath',
assert: (out, input) => {
if (!out) {
throw new Error('early-trip gate fixture must exceed the budget')
}
if (input.length > REQUESTED_SNAPSHOT_BYTE_BUDGET) {
throw new Error('early-trip fixture must not hit the code-unit short circuit')
}
}
}
),
gateScenario(
'chunk gate early-trip',
TERMINAL_STREAM_CHUNK_BYTES,
(sampleId) => makeEarlyTripText(TERMINAL_STREAM_CHUNK_BYTES, sampleId),
{
branch: 'nativeFastPath',
assert: (out, input) => {
if (!out) {
throw new Error('early-trip chunk fixture must exceed the budget')
}
if (input.length > TERMINAL_STREAM_CHUNK_BYTES) {
throw new Error('early-trip fixture must not hit the code-unit short circuit')
}
}
}
),
gateScenario(
`chunk gate ${TERMINAL_STREAM_CHUNK_BYTES / 1024}KiB`,
TERMINAL_STREAM_CHUNK_BYTES,
(sampleId) => makeTerminalTextUnderBytes(TERMINAL_STREAM_CHUNK_BYTES, sampleId),
{
branch: 'nativeFastPath',
assert: (out) => {
if (out) {
throw new Error('chunk gate fixture should sit under the chunk budget')
}
}
}
)
]
const pad = (value, width) => String(value).padStart(width)
const formatTime = (ms) =>
ms >= 0.001 ? `${(ms * 1000).toFixed(1)} us` : `${(ms * 1e6).toFixed(1)} ns`
console.log('Production terminal byte-measurement paths. Lower is better.')
console.log(
`iterations=${ITERATIONS} (${ITERATIONS * 2} counterbalanced batches/scenario, per-arm medians)`
)
console.log(`${pad('scenario', 30)} ${pad('legacy', 12)} ${pad('new', 12)} ${pad('speedup', 9)}`)
for (const scenario of scenarios) {
const { legacy, next } = runScenario(scenario)
console.log(
`${pad(scenario.label, 30)} ${pad(formatTime(legacy), 12)} ${pad(formatTime(next), 12)} ${pad(`${(legacy / next).toFixed(2)}x`, 9)}`
)
}
// Small-chunk sweep across real interactive PTY write sizes. The floor makes everything below
// MIN_NATIVE_BYTE_LENGTH_CODE_UNITS byte-identical to the legacy scan, so those rows must land
// at ~1.00x; anything materially below that is a regression the change would be shipping.
{
console.log(
`\nbatcher push small-chunk sweep (stopAfterBytes=${TERMINAL_OUTPUT_BATCH_MAX_BYTES}):`
)
console.log(
`${pad('bytes', 10)} ${pad('legacy', 12)} ${pad('new', 12)} ${pad('speedup', 9)} branch`
)
for (const codeUnits of [4, 8, 16, 64, 256, 1024, 4096]) {
const expectedBranch =
codeUnits >= MIN_NATIVE_BYTE_LENGTH_CODE_UNITS ? 'nativeFastPath' : 'scanFallback'
const { legacy, next } = runScenario(
batchScenario(`sweep ${codeUnits}`, (sampleId) => makeInteractiveText(codeUnits, sampleId), {
branch: expectedBranch,
repeats: Math.max(64, Math.min(4096, Math.ceil(2 ** 18 / codeUnits)))
})
)
const branch = expectedBranch === 'nativeFastPath' ? 'native' : 'scan (unchanged)'
console.log(
`${pad(`${codeUnits} B`, 10)} ${pad(formatTime(legacy), 12)} ${pad(formatTime(next), 12)} ${pad(`${(legacy / next).toFixed(2)}x`, 9)} ${branch}`
)
}
}
console.log(`\nvalidated=${validatedPairs} measured pairs, result checksum=${resultChecksum >>> 0}`)
+3 -14
View File
@@ -38,6 +38,7 @@ import { join, resolve } from 'node:path'
// `node_modules`). If electron-builder ever drops it, promote this to a
// direct devDependency in package.json.
import { extractFile, listPackage } from '@electron/asar'
import { BUILD_IDENTITY_RE, WRITE_KEY_RE } from './telemetry-bundle-constant-patterns.mjs'
// Why resolve from import.meta.url instead of cwd: a release runner (or a
// developer debugging locally) may invoke this script from a non-root cwd.
@@ -118,20 +119,8 @@ for (const m of asarMatches) {
// Why these regexes: electron-vite's `define` block substitutes the bare
// identifiers `ORCA_BUILD_IDENTITY` and `ORCA_POSTHOG_WRITE_KEY` with their
// JSON-stringified values at build time. `src/main/telemetry/client.ts`
// then assigns those into module-local consts named `BUILD_IDENTITY` and
// `WRITE_KEY`. electron-vite's main config is not minified (Vite default for
// Electron main builds), so Rollup emits the substituted constants verbatim
// as `const BUILD_IDENTITY = "stable";`. Match that exact emitted shape so a
// regression — e.g. the env var unset and the substitution falling back to
// literal `null` — fails the grep instead of slipping through as a falsy-
// but-stringy value. NOTE: if `build.minify` is ever enabled on the main
// bundle, esbuild/terser will rename top-level consts and this regex must
// be revisited (or replaced with a value-based assertion).
//
// WRITE_KEY char class includes `_` and `-` because PostHog project API
// keys use URL-safe base64 alphabet beyond `phc_`.
const BUILD_IDENTITY_RE = /const\s+BUILD_IDENTITY\s*=\s*"(rc|stable)"/
const WRITE_KEY_RE = /const\s+WRITE_KEY\s*=\s*"(phc_[A-Za-z0-9_-]+)"/
// then assigns those into module-local declarations named `BUILD_IDENTITY`
// and `WRITE_KEY`. Rollup may preserve `const` or lower it to `var`.
function verifyAsar(asarPath) {
console.log(`Verifying ${asarPath}`)
@@ -0,0 +1,191 @@
import * as Notifications from 'expo-notifications'
import { Platform } from 'react-native'
import { loadPushNotificationsEnabled } from '../storage/preferences'
import { buildLocalNotificationData, type DesktopNotificationSource } from './notification-routing'
import { ensureNotificationPermissions } from './notification-permissions'
export type NotificationEvent = {
type: 'notification'
source: DesktopNotificationSource
title: string
body: string
worktreeId?: string
notificationId?: string
// Desktop-assigned seq for reconnect catch-up (#8129); optional since older runtimes may omit it.
notificationSeq?: number
// Counter lifetime the seq belongs to (#8591); absent on older runtimes.
notificationEpoch?: string
}
export type DismissNotificationEvent = {
type: 'dismiss'
notificationId: string
notificationSeq?: number
notificationEpoch?: string
}
type ScheduledNotificationState = {
identifier?: string
pending?: Promise<string | null>
dismissAfterSchedule?: boolean
}
const scheduledNotificationsByHostAndNotificationId = new Map<string, ScheduledNotificationState>()
// Why: keys never repeat and are only freed on desktop dismiss (which remote users often miss), so bound the map to stop unbounded growth.
const MAX_SCHEDULED_NOTIFICATIONS = 256
let maxScheduledNotifications = MAX_SCHEDULED_NOTIFICATIONS
function getStoredNotificationKey(hostId: string, notificationId: string): string {
return `${encodeURIComponent(hostId)}:${encodeURIComponent(notificationId)}`
}
// Evict oldest settled entries (never mid-schedule); Map iteration is insertion order so the first match is oldest.
function boundScheduledNotifications(): void {
while (scheduledNotificationsByHostAndNotificationId.size > maxScheduledNotifications) {
let evicted = false
for (const [key, state] of scheduledNotificationsByHostAndNotificationId) {
if (!state.pending) {
scheduledNotificationsByHostAndNotificationId.delete(key)
evicted = true
break
}
}
if (!evicted) {
break
}
}
}
/** Test-only: override the cap (pass no arg to restore the default). */
export function setScheduledNotificationsMaxForTests(max?: number): void {
maxScheduledNotifications = max ?? MAX_SCHEDULED_NOTIFICATIONS
}
export function configureNotificationChannel(): void {
if (Platform.OS === 'android') {
void Notifications.setNotificationChannelAsync('orca-desktop', {
name: 'Desktop Notifications',
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250],
lightColor: '#6366f1'
})
}
}
export async function showLocalNotification(
event: NotificationEvent,
hostId: string
): Promise<void> {
const storedKey = event.notificationId
? getStoredNotificationKey(hostId, event.notificationId)
: null
if (!storedKey) {
const enabled = await loadPushNotificationsEnabled()
if (!enabled) {
return
}
const granted = await ensureNotificationPermissions()
if (!granted) {
return
}
await Notifications.scheduleNotificationAsync({
content: {
title: event.title,
body: event.body,
data: buildLocalNotificationData(event, hostId),
...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {})
},
trigger: null
})
return
}
let state = scheduledNotificationsByHostAndNotificationId.get(storedKey)
if (state?.pending) {
return
}
if (!state) {
state = {}
scheduledNotificationsByHostAndNotificationId.set(storedKey, state)
}
const notificationState = state
const pending = (async () => {
const enabled = await loadPushNotificationsEnabled()
if (!enabled) {
return null
}
const granted = await ensureNotificationPermissions()
if (!granted) {
return null
}
if (notificationState.identifier) {
await Notifications.dismissNotificationAsync(notificationState.identifier).catch(() => {})
notificationState.identifier = undefined
}
return Notifications.scheduleNotificationAsync({
content: {
title: event.title,
body: event.body,
data: buildLocalNotificationData(event, hostId),
...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {})
},
trigger: null
})
})()
notificationState.pending = pending
try {
const scheduledIdentifier = await pending
if (!scheduledIdentifier) {
if (!notificationState.identifier) {
scheduledNotificationsByHostAndNotificationId.delete(storedKey)
}
return
}
if (notificationState.dismissAfterSchedule) {
notificationState.dismissAfterSchedule = false
scheduledNotificationsByHostAndNotificationId.delete(storedKey)
await Notifications.dismissNotificationAsync(scheduledIdentifier).catch(() => {})
return
}
notificationState.identifier = scheduledIdentifier
boundScheduledNotifications()
} finally {
if (notificationState.pending === pending) {
notificationState.pending = undefined
notificationState.dismissAfterSchedule = false
}
}
}
export async function dismissLocalNotification(
event: DismissNotificationEvent,
hostId: string
): Promise<void> {
if (!event.notificationId) {
return
}
const storedKey = getStoredNotificationKey(hostId, event.notificationId)
const state = scheduledNotificationsByHostAndNotificationId.get(storedKey)
if (!state) {
return
}
if (state.pending) {
// Why: dismiss can arrive while the OS is still scheduling; defer it so no stale banner survives.
state.dismissAfterSchedule = true
return
}
if (!state.identifier) {
return
}
scheduledNotificationsByHostAndNotificationId.delete(storedKey)
await Notifications.dismissNotificationAsync(state.identifier).catch(() => {})
}
@@ -9,6 +9,7 @@ import {
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { RpcClient } from '../transport/rpc-client'
import { loadPushNotificationsEnabled } from '../storage/preferences'
import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup'
vi.mock('expo-notifications', () => ({
AndroidImportance: { HIGH: 'high' },
@@ -39,6 +40,10 @@ vi.mock('../storage/preferences', () => ({
beforeEach(() => {
Object.assign(Platform, { OS: 'ios', Version: 18 })
// Why (#8591): the reconnect watermark/seen-set now live per host at module
// scope so they survive the app's unsubscribe-on-disconnect. Reset between
// tests so each case starts from a genuine cold open.
resetHostNotificationSessionsForTests()
})
describe('getNotificationPermissionState', () => {
@@ -68,10 +73,14 @@ describe('subscribeToDesktopNotifications', () => {
vi.clearAllMocks()
})
async function flushAsync(): Promise<void> {
for (let i = 0; i < 10; i += 1) {
await Promise.resolve()
}
// Why the macrotask and not N microtask ticks (#8591): deliveries now run through
// the per-host serialization queue, so a delivery is several more `await` hops deep
// than it used to be and a fixed tick count silently under-drains. Yielding to the
// macrotask queue drains whatever depth the chain happens to have.
function flushAsync(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 0)
})
}
function makeDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
@@ -489,6 +498,120 @@ describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
expect(scheduledIds.filter((id) => id === 'agent:dup')).toHaveLength(1)
})
it('voids a persisted watermark whose epoch predates a desktop restart', async () => {
// #8591: the desktop's seq counter restarts at 0 each launch while this watermark
// is persisted. Reconnecting to a restarted desktop with seq 57 would make
// `57 >= 2` true and silently kill catch-up. The epoch on 'ready' is what tells
// the client the counter changed, so the stale watermark must be dropped.
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1')
vi.mocked(AsyncStorage.getItem).mockImplementation(async (key: string) =>
key.startsWith('orca:mobileNotificationsWatermark:')
? JSON.stringify({ seq: 57, epoch: 'epoch-before-restart' })
: null
)
const sub = makeClient()
subscribeToDesktopNotifications(sub.client, 'host-1')
// Cold open under the OLD desktop process, so the watermark loads as 57.
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-before-restart' })
await flushAsync()
await flushAsync()
// Desktop restarts: new epoch, counter back near 0.
sub.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-after-restart' })
await flushAsync()
await flushAsync()
const missedCalls = vi
.mocked(sub.client.sendRequest)
.mock.calls.filter((c: unknown[]) => c[0] === 'notifications.getMissedSince')
// The cold open catches up from its stored watermark against the SAME counter —
// 57 is meaningful there, so it is the correct cut (#8591 second pass).
expect(missedCalls[0]?.[1]).toEqual({ lastSeenSeq: 57, epoch: 'epoch-before-restart' })
// After the restart the watermark is reset to 0 and tagged with the live epoch —
// not the stale 57, which would make `57 >= 2` true and kill catch-up silently.
expect(missedCalls.at(-1)?.[1]).toEqual({ lastSeenSeq: 0, epoch: 'epoch-after-restart' })
})
it('refuses to seed a stored watermark that lost the race to a newer live epoch', async () => {
// The seed read is deliberately not awaited (so subscribe doesn't block on
// AsyncStorage), which means it can land AFTER 'ready' already adopted the live
// epoch. If it seeds unconditionally it reinstates the exact stale cut #8591 is
// about — the reset having already happened doesn't help, because the seed runs
// last and wins. Only a stored epoch matching the live one may seed.
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1')
// Hold the storage read open so 'ready' is guaranteed to be processed first.
let releaseStorage: () => void = () => {}
const storageGate = new Promise<void>((resolve) => {
releaseStorage = resolve
})
vi.mocked(AsyncStorage.getItem).mockImplementation(async (key: string) => {
await storageGate
return key.startsWith('orca:mobileNotificationsWatermark:')
? JSON.stringify({ seq: 57, epoch: 'epoch-before-restart' })
: null
})
const sub = makeClient()
subscribeToDesktopNotifications(sub.client, 'host-1')
// Live epoch adopted while the stored one is still in flight.
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-after-restart' })
await flushAsync()
releaseStorage()
await flushAsync()
sub.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-after-restart' })
await flushAsync()
await flushAsync()
const missedCall = vi
.mocked(sub.client.sendRequest)
.mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince')
expect(missedCall?.[1]).toEqual({ lastSeenSeq: 0, epoch: 'epoch-after-restart' })
})
it('keeps the persisted watermark when the desktop epoch is unchanged', async () => {
// The reset must be narrow: a plain socket reap with the same desktop process
// still has to send the real watermark, or every reconnect re-pushes the buffer.
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1')
vi.mocked(AsyncStorage.getItem).mockImplementation(async (key: string) =>
key.startsWith('orca:mobileNotificationsWatermark:')
? JSON.stringify({ seq: 57, epoch: 'epoch-stable' })
: null
)
const sub = makeClient()
subscribeToDesktopNotifications(sub.client, 'host-1')
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-stable' })
await flushAsync()
await flushAsync()
sub.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-stable' })
await flushAsync()
await flushAsync()
const missedCall = vi
.mocked(sub.client.sendRequest)
.mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince')
expect(missedCall?.[1]).toEqual({ lastSeenSeq: 57, epoch: 'epoch-stable' })
})
it('drops an already-seen id if a replay re-includes it (defense-in-depth)', async () => {
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
@@ -577,8 +700,8 @@ describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
await flushAsync()
expect(AsyncStorageMock.setItem).toHaveBeenCalledWith(
'orca:mobileNotificationsLastSeq:host-1',
'5'
'orca:mobileNotificationsWatermark:host-1',
JSON.stringify({ seq: 5, epoch: null })
)
})
@@ -627,8 +750,8 @@ describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
// Watermark advanced to the replayed seq and was persisted.
expect(AsyncStorageMock.setItem).toHaveBeenCalledWith(
'orca:mobileNotificationsLastSeq:host-1',
'8'
'orca:mobileNotificationsWatermark:host-1',
JSON.stringify({ seq: 8, epoch: null })
)
// Second reconnect resumes from the advanced watermark, not 0.
@@ -639,4 +762,208 @@ describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
.mock.calls.filter((c: unknown[]) => c[0] === 'notifications.getMissedSince')
expect(missedCalls.at(-1)?.[1]).toEqual({ lastSeenSeq: 8 })
})
it('replays a terminal bell at a seq the previous desktop counter already used', async () => {
// Round-1 review finding: seen-keys are seq-derived, and terminal bells carry no
// notificationId (they key on `seq:N` alone). Epoch A delivers a bell at seq 1;
// after a restart, epoch B's first bell is ALSO seq 1. The catch-up path is the
// one that consults the seen-set, so without clearing it on epoch change the
// replayed post-restart bell is mistaken for a duplicate and silently skipped —
// #8591's silent loss again, now one notification at a time.
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
const sub = makeClient()
// Catch-up returns epoch B's first bell — same seq 1 the old counter used.
sub.client.sendRequest = vi.fn(async (method: string) => {
if (method === 'notifications.getMissedSince') {
return {
ok: true,
result: {
epoch: 'epoch-B',
notifications: [{ type: 'notification', title: 'bell', body: 'B', notificationSeq: 1 }]
}
} as never
}
return { ok: true, result: undefined } as never
})
subscribeToDesktopNotifications(sub.client, 'host-1')
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-A' })
await flushAsync()
// A live bell under epoch A — no notificationId, so its seen-key is `seq:1`.
sub.onData?.({ type: 'notification', title: 'bell', body: 'A', notificationSeq: 1 })
await flushAsync()
expect(vi.mocked(Notifications.scheduleNotificationAsync).mock.calls.length).toBe(1)
// Desktop restarts; reconnect triggers catch-up against the fresh counter.
sub.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-B' })
await flushAsync()
await flushAsync()
// The post-restart bell must reach the user, not be swallowed as a stale `seq:1`.
expect(vi.mocked(Notifications.scheduleNotificationAsync).mock.calls.length).toBe(2)
})
it('does not trust a legacy epoch-less watermark against a live counter', async () => {
// Round-1 review finding: pre-upgrade installs stored a bare seq with no epoch.
// Seeding it and then treating the first observed epoch as "nothing changed"
// leaves 57 cutting a counter it was never measured against — #8591 reached
// through the upgrade path. An unprovenanced seq may not survive epoch adoption.
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
// Only the LEGACY key exists — exactly what an upgrading install has on disk.
vi.mocked(AsyncStorage.getItem).mockImplementation(async (key: string) =>
key.startsWith('orca:mobileNotificationsLastSeq:') ? '57' : null
)
const sub = makeClient()
subscribeToDesktopNotifications(sub.client, 'host-1')
// Seed lands FIRST (no epoch known yet), so 57 is provisionally adopted...
await flushAsync()
await flushAsync()
// ...then the live epoch arrives for the first time.
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-live' })
await flushAsync()
sub.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-live' })
await flushAsync()
await flushAsync()
const missedCall = vi
.mocked(sub.client.sendRequest)
.mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince')
// Must not be 57: that seq was never shown to belong to this counter.
expect(missedCall?.[1]).toEqual({ lastSeenSeq: 0, epoch: 'epoch-live' })
})
it('catches up on the FIRST connection after an upgrade, without a second ready', async () => {
// Round-2 review finding: catch-up hung off `connectedBefore`, which is false on
// the first 'ready' of a process. So a cold app open — post-upgrade, or after the
// OS evicted the app — adopted the epoch but never replayed. Everything between
// the stored watermark and the next live seq was then lost permanently, because
// the first live event advances the watermark past the gap.
//
// The earlier migration test masked this by emitting a SECOND 'ready'. This one
// emits exactly one, which is what a real cold open does.
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
vi.mocked(AsyncStorage.getItem).mockImplementation(async (key: string) =>
key.startsWith('orca:mobileNotificationsWatermark:')
? JSON.stringify({ seq: 57, epoch: 'epoch-live' })
: null
)
const sub = makeClient()
vi.mocked(sub.client.sendRequest).mockImplementation(async (method: string) =>
method === 'notifications.getMissedSince'
? {
ok: true,
result: {
epoch: 'epoch-live',
notifications: [
{
type: 'notification',
notificationId: 'missed-58',
notificationSeq: 58,
notificationEpoch: 'epoch-live',
title: 'while the app was closed',
body: 'b'
}
]
}
}
: { ok: true, result: {} }
)
subscribeToDesktopNotifications(sub.client, 'host-1')
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-live' })
await flushAsync()
await flushAsync()
await flushAsync()
const missedCall = vi
.mocked(sub.client.sendRequest)
.mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince')
// The single 'ready' must replay from the stored watermark, not skip it.
expect(missedCall?.[1]).toEqual({ lastSeenSeq: 57, epoch: 'epoch-live' })
// And the missed notification must actually reach the user.
expect(vi.mocked(Notifications.scheduleNotificationAsync).mock.calls.length).toBe(1)
})
it('does not replay the desktop buffer at a first-ever pairing', async () => {
// The other side of the finding above: with nothing stored, this device has never
// delivered for this host. Catching up would push the whole retained buffer at a
// user who was never subscribed for any of it.
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(AsyncStorage.getItem).mockResolvedValue(null)
const sub = makeClient()
subscribeToDesktopNotifications(sub.client, 'host-1')
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-live' })
await flushAsync()
await flushAsync()
await flushAsync()
expect(
vi
.mocked(sub.client.sendRequest)
.mock.calls.filter((c: unknown[]) => c[0] === 'notifications.getMissedSince')
).toHaveLength(0)
})
it('persists seq and epoch as one value so a crash cannot split the pair', async () => {
// Round-1 review finding: written as two keys, a process death between the writes
// leaves epoch-B beside seq-57-from-A. That pair looks internally valid on the
// next launch and is therefore trusted — silently cutting B's first 57 events.
// One key means the pair is always written whole or not at all.
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
const sub = makeClient()
subscribeToDesktopNotifications(sub.client, 'host-1')
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-A' })
await flushAsync()
sub.onData?.({
type: 'notification',
title: 't',
body: 'b',
notificationId: 'agent:x',
notificationSeq: 9
})
await flushAsync()
// Every watermark write is a single key carrying both halves together.
const watermarkWrites = AsyncStorageMock.setItem.mock.calls.filter((c: unknown[]) =>
String(c[0]).startsWith('orca:mobileNotifications')
)
expect(watermarkWrites.length).toBeGreaterThan(0)
for (const [key, value] of watermarkWrites) {
expect(key).toBe('orca:mobileNotificationsWatermark:host-1')
expect(JSON.parse(String(value))).toHaveProperty('epoch')
expect(JSON.parse(String(value))).toHaveProperty('seq')
}
expect(JSON.parse(String(watermarkWrites.at(-1)?.[1]))).toEqual({
seq: 9,
epoch: 'epoch-A'
})
})
})
+162 -262
View File
@@ -1,228 +1,34 @@
import * as Notifications from 'expo-notifications'
import { Platform } from 'react-native'
import type { RpcClient } from '../transport/rpc-client'
import { loadPushNotificationsEnabled } from '../storage/preferences'
import { buildLocalNotificationData, type DesktopNotificationSource } from './notification-routing'
// Re-exported so the existing importers (and their vi.mock paths) keep working.
export {
ensureNotificationPermissions,
getNotificationPermissionState,
type NotificationPermissionState
} from './notification-permissions'
export { setScheduledNotificationsMaxForTests } from './local-notification-scheduling'
import {
createSeenNotificationGuard,
loadLastSeenSeq,
saveLastSeenSeq,
seenKeyForEvent
configureNotificationChannel,
dismissLocalNotification,
showLocalNotification,
type DismissNotificationEvent,
type NotificationEvent
} from './local-notification-scheduling'
import {
adoptNotificationEpoch,
enqueueHostDelivery,
getHostNotificationSession,
releaseQueuedShowNotificationId,
saveWatermark,
seedWatermarkFromStorage,
seenKeyForEvent,
shouldQueueShowForNotificationId
} from './notification-reconnect-catchup'
type NotificationEvent = {
type: 'notification'
source: DesktopNotificationSource
title: string
body: string
worktreeId?: string
notificationId?: string
// Desktop-assigned seq for reconnect catch-up (#8129); optional since older runtimes may omit it.
notificationSeq?: number
}
type DismissNotificationEvent = {
type: 'dismiss'
notificationId: string
notificationSeq?: number
}
type SubscribeResult = {
type: 'ready'
subscriptionId: string
}
type ScheduledNotificationState = {
identifier?: string
pending?: Promise<string | null>
dismissAfterSchedule?: boolean
}
const scheduledNotificationsByHostAndNotificationId = new Map<string, ScheduledNotificationState>()
// Why: keys never repeat and are only freed on desktop dismiss (which remote users often miss), so bound the map to stop unbounded growth.
const MAX_SCHEDULED_NOTIFICATIONS = 256
let maxScheduledNotifications = MAX_SCHEDULED_NOTIFICATIONS
function getStoredNotificationKey(hostId: string, notificationId: string): string {
return `${encodeURIComponent(hostId)}:${encodeURIComponent(notificationId)}`
}
// Evict oldest settled entries (never mid-schedule); Map iteration is insertion order so the first match is oldest.
function boundScheduledNotifications(): void {
while (scheduledNotificationsByHostAndNotificationId.size > maxScheduledNotifications) {
let evicted = false
for (const [key, state] of scheduledNotificationsByHostAndNotificationId) {
if (!state.pending) {
scheduledNotificationsByHostAndNotificationId.delete(key)
evicted = true
break
}
}
if (!evicted) {
break
}
}
}
/** Test-only: override the cap (pass no arg to restore the default). */
export function setScheduledNotificationsMaxForTests(max?: number): void {
maxScheduledNotifications = max ?? MAX_SCHEDULED_NOTIFICATIONS
}
export type NotificationPermissionState = {
granted: boolean
status: string
canAskAgain: boolean
authorizationReflectsUserChoice: boolean
}
export async function getNotificationPermissionState(): Promise<NotificationPermissionState> {
const { status, canAskAgain } = await Notifications.getPermissionsAsync()
return {
granted: status === 'granted',
status,
canAskAgain,
// Why: Android <33 has no runtime notification permission, so "granted" is capability, not user consent.
authorizationReflectsUserChoice:
status === 'granted' && (Platform.OS !== 'android' || Number(Platform.Version) >= 33)
}
}
// Why: re-read OS state every call — users can change it in Settings while Orca is backgrounded.
export async function ensureNotificationPermissions(): Promise<boolean> {
const existing = await getNotificationPermissionState()
if (existing.granted) {
return true
}
const { status } = await Notifications.requestPermissionsAsync()
return status === 'granted'
}
function configureNotificationChannel(): void {
if (Platform.OS === 'android') {
void Notifications.setNotificationChannelAsync('orca-desktop', {
name: 'Desktop Notifications',
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250],
lightColor: '#6366f1'
})
}
}
async function showLocalNotification(event: NotificationEvent, hostId: string): Promise<void> {
const storedKey = event.notificationId
? getStoredNotificationKey(hostId, event.notificationId)
: null
if (!storedKey) {
const enabled = await loadPushNotificationsEnabled()
if (!enabled) {
return
}
const granted = await ensureNotificationPermissions()
if (!granted) {
return
}
await Notifications.scheduleNotificationAsync({
content: {
title: event.title,
body: event.body,
data: buildLocalNotificationData(event, hostId),
...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {})
},
trigger: null
})
return
}
let state = scheduledNotificationsByHostAndNotificationId.get(storedKey)
if (state?.pending) {
return
}
if (!state) {
state = {}
scheduledNotificationsByHostAndNotificationId.set(storedKey, state)
}
const notificationState = state
const pending = (async () => {
const enabled = await loadPushNotificationsEnabled()
if (!enabled) {
return null
}
const granted = await ensureNotificationPermissions()
if (!granted) {
return null
}
if (notificationState.identifier) {
await Notifications.dismissNotificationAsync(notificationState.identifier).catch(() => {})
notificationState.identifier = undefined
}
return Notifications.scheduleNotificationAsync({
content: {
title: event.title,
body: event.body,
data: buildLocalNotificationData(event, hostId),
...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {})
},
trigger: null
})
})()
notificationState.pending = pending
try {
const scheduledIdentifier = await pending
if (!scheduledIdentifier) {
if (!notificationState.identifier) {
scheduledNotificationsByHostAndNotificationId.delete(storedKey)
}
return
}
if (notificationState.dismissAfterSchedule) {
notificationState.dismissAfterSchedule = false
scheduledNotificationsByHostAndNotificationId.delete(storedKey)
await Notifications.dismissNotificationAsync(scheduledIdentifier).catch(() => {})
return
}
notificationState.identifier = scheduledIdentifier
boundScheduledNotifications()
} finally {
if (notificationState.pending === pending) {
notificationState.pending = undefined
notificationState.dismissAfterSchedule = false
}
}
}
async function dismissLocalNotification(
event: DismissNotificationEvent,
hostId: string
): Promise<void> {
if (!event.notificationId) {
return
}
const storedKey = getStoredNotificationKey(hostId, event.notificationId)
const state = scheduledNotificationsByHostAndNotificationId.get(storedKey)
if (!state) {
return
}
if (state.pending) {
// Why: dismiss can arrive while the OS is still scheduling; defer it so no stale banner survives.
state.dismissAfterSchedule = true
return
}
if (!state.identifier) {
return
}
scheduledNotificationsByHostAndNotificationId.delete(storedKey)
await Notifications.dismissNotificationAsync(state.identifier).catch(() => {})
// Desktop counter lifetime (#8591); absent from runtimes that predate it.
epoch?: string
}
// Per-connection subscription; a reconnect `ready` triggers watermarked catch-up (#8129) so already-pushed events aren't re-sent.
@@ -231,68 +37,129 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
let subscriptionId: string | null = null
let disposed = false
// Highest seq delivered (live or replay) this connection; persisted per-host so cold start resumes from the right cut.
let lastDeliveredSeq = 0
// Why: defense-in-depth dedup for replayed events if the desktop's bounded buffer evicted across a reconnect boundary.
const seenReplay = createSeenNotificationGuard()
// Why (#8591): survives the unsubscribe/resubscribe the app performs on every
// socket drop, so a reconnect still knows its watermark and that it reconnected.
const session = getHostNotificationSession(hostId)
function deliverLive(
/**
* Queue one delivery on the host chain, dropping a show whose notificationId
* already has one queued.
*
* Why the claim is taken HERE and not inside deliverLive (#8591): the point of
* the dedup is to notice a second event arriving while the first is still
* outstanding. Inside the queued task the first has already finished, so the
* overlap is no longer observable — it has to be checked before enqueueing.
*/
function queueDelivery(
type: 'notification' | 'dismiss',
event: NotificationEvent | DismissNotificationEvent
): Promise<void> {
if (event.notificationSeq != null && event.notificationSeq > lastDeliveredSeq) {
lastDeliveredSeq = event.notificationSeq
void saveLastSeenSeq(hostId, lastDeliveredSeq)
if (
type === 'notification' &&
!shouldQueueShowForNotificationId(session, event.notificationId)
) {
return Promise.resolve()
}
return enqueueHostDelivery(session, async () => {
try {
await deliverLive(type, event)
} finally {
if (type === 'notification') {
releaseQueuedShowNotificationId(session, event.notificationId)
}
}
})
}
async function deliverLive(
type: 'notification' | 'dismiss',
event: NotificationEvent | DismissNotificationEvent
): Promise<void> {
adoptNotificationEpoch(session, hostId, event.notificationEpoch)
// Why (#8129): mark seen on the live path too, so a later replay of an already-pushed id dedups instead of double-pushing.
const key = seenKeyForEvent(event)
if (key) {
seenReplay.add(key)
session.seen.add(key)
}
if (type === 'notification') {
return showLocalNotification(event as NotificationEvent, hostId)
await showLocalNotification(event as NotificationEvent, hostId)
} else {
await dismissLocalNotification(event as DismissNotificationEvent, hostId)
}
// Why after the await (#8591): the watermark is a promise that everything up
// to this seq has been shown. Advancing it before the local notification lands
// means a process death in between silently drops it — the next launch asks the
// desktop for seq greater than one the user never saw.
if (event.notificationSeq != null && event.notificationSeq > session.lastDeliveredSeq) {
session.lastDeliveredSeq = event.notificationSeq
// Persisted as a pair: a seq is only trustworthy alongside the epoch it indexes.
void saveWatermark(hostId, {
seq: session.lastDeliveredSeq,
epoch: session.lastDeliveredEpoch
})
}
return dismissLocalNotification(event as DismissNotificationEvent, hostId)
}
// Why: desktop cuts by seq > lastSeenSeq, so re-fetching from the watermark is idempotent (seenReplay guards residual overlap).
// Why: desktop cuts by seq > lastSeenSeq, so re-fetching from the watermark is idempotent (session.seen guards residual overlap).
async function fetchMissed(): Promise<void> {
if (disposed) {
return
}
const missed = await client
.sendRequest('notifications.getMissedSince', { lastSeenSeq: lastDeliveredSeq })
.sendRequest('notifications.getMissedSince', {
lastSeenSeq: session.lastDeliveredSeq,
// Why: sending the epoch lets the desktop reject a watermark from a counter
// it no longer has and return the whole retained buffer instead of nothing.
...(session.lastDeliveredEpoch != null ? { epoch: session.lastDeliveredEpoch } : {})
})
.then((response) => {
if (!response.ok) {
return []
}
const result = response.result as { notifications?: unknown[] } | undefined
const result = response.result as { notifications?: unknown[]; epoch?: string } | undefined
adoptNotificationEpoch(session, hostId, result?.epoch)
return Array.isArray(result?.notifications) ? result.notifications : []
})
.catch(() => [])
for (const raw of missed) {
const event = raw as NotificationEvent | DismissNotificationEvent
const key = seenKeyForEvent(event)
if (key && seenReplay.has(key)) {
continue
// Why the whole batch is ONE queue entry (#8591): awaiting per event returns to
// the event loop between replays, so a live seq 11 slots into the chain between
// seq 6 and 7 and persists a watermark past a notification still unshown. Why the
// request stays OUTSIDE the queue: sendRequest waits up to 30s, and holding the
// chain for that would stall live delivery on a slow link.
await enqueueHostDelivery(session, async () => {
for (const raw of missed) {
// Re-checked per event: the batch can start before a teardown and still be
// draining after it, and a torn-down host must stop pushing.
if (disposed) {
return
}
const event = raw as NotificationEvent | DismissNotificationEvent
const key = seenKeyForEvent(event)
if (key && session.seen.has(key)) {
continue
}
if (key) {
session.seen.add(key)
}
// Claimed inline rather than via queueDelivery: the batch is already one
// queue entry, and re-enqueueing per item is what let a live event cut in.
if (event.type === 'notification') {
if (!shouldQueueShowForNotificationId(session, event.notificationId)) {
continue
}
try {
await deliverLive('notification', event)
} finally {
releaseQueuedShowNotificationId(session, event.notificationId)
}
} else if (event.type === 'dismiss') {
await deliverLive('dismiss', event)
}
}
if (key) {
seenReplay.add(key)
}
if (event.type === 'notification') {
await deliverLive('notification', event)
} else if (event.type === 'dismiss') {
await deliverLive('dismiss', event)
}
}
})
}
// Why: seed the watermark lazily so subscribe() doesn't block on an AsyncStorage read.
let watermarkLoaded = false
void loadLastSeenSeq(hostId).then((seq) => {
lastDeliveredSeq = Math.max(lastDeliveredSeq, seq)
watermarkLoaded = true
})
seedWatermarkFromStorage(session, hostId)
function unsubscribeServer(id: string) {
if (client.getState() === 'connected') {
@@ -300,7 +167,6 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
}
}
let reconnectReadyCount = 0
const unsubscribeStream = client.subscribe('notifications.subscribe', {}, (data: unknown) => {
const event = data as
| NotificationEvent
@@ -309,16 +175,34 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
| { type: 'end' }
if (event.type === 'ready') {
subscriptionId = (event as SubscribeResult).subscriptionId
reconnectReadyCount += 1
const isReconnect = session.connectedBefore
session.connectedBefore = true
if (disposed) {
unsubscribeServer(subscriptionId)
unsubscribeStream()
return
}
// Why: only reconnects fetch missed; watermarkLoaded guards against fetching from a stale 0 (which re-pushes everything).
if (reconnectReadyCount > 1 && watermarkLoaded) {
void fetchMissed()
}
const readyEpoch = (event as SubscribeResult).epoch
// Why (#8591) the await: on a cold app open the persisted read is still in
// flight, so deciding here would see watermarkLoaded false and skip catch-up —
// which is precisely the post-upgrade / post-process-death case that loses
// every notification between the stored watermark and the next live seq.
void (async () => {
await session.watermarkSeeded
if (disposed) {
return
}
// Why before fetchMissed: adopting the epoch here is what voids a watermark
// left over from a previous desktop lifetime, so the catch-up request carries
// a watermark that means something against the counter now answering it.
adoptNotificationEpoch(session, hostId, readyEpoch)
// A reconnect always catches up. A cold open catches up only when this device
// has delivered for this host before — a first-ever pairing must not be handed
// the desktop's whole retained buffer.
if (isReconnect || session.hadStoredWatermark) {
await fetchMissed()
}
})()
return
}
if (event.type === 'end') {
@@ -330,11 +214,27 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
if (disposed) {
return
}
if (event.type === 'notification') {
void deliverLive('notification', event as NotificationEvent)
} else if (event.type === 'dismiss') {
void deliverLive('dismiss', event as DismissNotificationEvent)
if (event.type !== 'notification' && event.type !== 'dismiss') {
return
}
// Why the await (#8591): deliverLive advances the watermark. A live event landing
// while the persisted read is still in flight would push it past the buffered seqs
// the catch-up is about to ask for, and getMissedSince would cut them. Ordering is
// preserved — every handler waits on the same promise, and the 'ready' continuation
// registered on it first, so catch-up still builds its request before any live seq.
const liveEvent = event
void (async () => {
await session.watermarkSeeded
if (disposed) {
return
}
// Why the queue (#8591): a live event must not overtake an in-flight
// catch-up replay, or it persists a watermark past seqs still unshown.
await queueDelivery(
liveEvent.type === 'notification' ? 'notification' : 'dismiss',
liveEvent as NotificationEvent | DismissNotificationEvent
)
})()
})
return () => {
@@ -0,0 +1,248 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import { subscribeToDesktopNotifications } from './mobile-notifications'
import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup'
import type { RpcClient } from '../transport/rpc-client'
import { loadPushNotificationsEnabled } from '../storage/preferences'
vi.mock('expo-notifications', () => ({
AndroidImportance: { HIGH: 'high' },
setNotificationChannelAsync: vi.fn(),
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
scheduleNotificationAsync: vi.fn(),
dismissNotificationAsync: vi.fn()
}))
vi.mock('react-native', () => ({
Platform: { OS: 'ios', Version: 18 }
}))
const WATERMARK_KEY = 'orca:mobileNotificationsWatermark:host-1'
const storage = new Map<string, string>()
let getItemImpl: (key: string) => Promise<string | null> = async (key) => storage.get(key) ?? null
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn((key: string) => getItemImpl(key)),
setItem: vi.fn(async (key: string, value: string) => {
storage.set(key, value)
})
}
}))
vi.mock('../storage/preferences', () => ({
loadPushNotificationsEnabled: vi.fn()
}))
function flushAsync(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 10)
})
}
function persistedSeq(): number {
return (JSON.parse(storage.get(WATERMARK_KEY) ?? '{}') as { seq?: number }).seq ?? 0
}
describe('#8591 per-host delivery ordering', () => {
beforeEach(() => {
vi.clearAllMocks()
storage.clear()
getItemImpl = async (key) => storage.get(key) ?? null
resetHostNotificationSessionsForTests()
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1')
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
})
it('never persists a watermark past a notification the catch-up has not shown', async () => {
// The watermark is a promise that everything up to that seq reached the user.
// If a live seq 11 is processed while catch-up is still showing seq 6, it
// persists 11 — and a process death before 7 is shown loses 7 forever, because
// the next launch asks the desktop for seq > 11. That is the original #8591
// loss re-entered through concurrency rather than through a restarted counter.
let releaseFirstShow!: () => void
const firstShowBlocked = new Promise<void>((resolve) => {
releaseFirstShow = resolve
})
let shown = 0
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async () => {
shown += 1
if (shown === 1) {
await firstShowBlocked
}
return 'sched-1'
})
let onData: ((data: unknown) => void) | null = null
const client = {
subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => {
onData = cb
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn(async (method: string) => {
if (method === 'notifications.getMissedSince') {
return {
ok: true,
result: {
notifications: [
{
type: 'notification',
title: 'm6',
body: 'b',
notificationId: 'a:6',
notificationSeq: 6
},
{
type: 'notification',
title: 'm7',
body: 'b',
notificationId: 'a:7',
notificationSeq: 7
}
]
}
} as never
}
return { ok: true, result: undefined } as never
})
} as unknown as RpcClient
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
subscribeToDesktopNotifications(client, 'host-1')
onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
// Live seq 11 arrives while the replay is wedged on seq 6.
onData?.({
type: 'notification',
title: 'live-11',
body: 'b',
notificationId: 'a:11',
notificationSeq: 11
})
await flushAsync()
expect(persistedSeq()).toBeLessThan(6)
releaseFirstShow()
await flushAsync()
// Once the chain drains, everything is shown and the watermark catches up.
expect(persistedSeq()).toBe(11)
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
expect(titles).toEqual(['m6', 'm7', 'live-11'])
})
it('shows one banner when a replay and a live event carry the same notification id', async () => {
// Serializing deliveries removed the overlap the old dedup relied on: the
// replay's show now COMPLETES before the live duplicate starts, so nothing is
// pending for it to observe and the user gets the same notification twice.
let releaseFirstShow!: () => void
const firstShowBlocked = new Promise<void>((resolve) => {
releaseFirstShow = resolve
})
let shown = 0
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async () => {
shown += 1
if (shown === 1) {
await firstShowBlocked
}
return `sched-${shown}`
})
let onData: ((data: unknown) => void) | null = null
const client = {
subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => {
onData = cb
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn(async (method: string) => {
if (method === 'notifications.getMissedSince') {
return {
ok: true,
result: {
notifications: [
{
type: 'notification',
title: 'dup',
body: 'b',
notificationId: 'agent:dup',
notificationSeq: 6
}
]
}
} as never
}
return { ok: true, result: undefined } as never
})
} as unknown as RpcClient
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
subscribeToDesktopNotifications(client, 'host-1')
onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
// Same id arrives live while the replay's show is still blocked. A different
// seq, so the seen-set does not catch it — only the queued-show claim does.
onData?.({
type: 'notification',
title: 'dup',
body: 'b',
notificationId: 'agent:dup',
notificationSeq: 7
})
await flushAsync()
releaseFirstShow()
await flushAsync()
expect(vi.mocked(Notifications.scheduleNotificationAsync)).toHaveBeenCalledTimes(1)
})
it('still delivers when the persisted watermark read never resolves', async () => {
// Every delivery awaits the seed, so a wedged AsyncStorage read would disable
// this host's notifications for the whole app lifetime — silently.
getItemImpl = () => new Promise<string | null>(() => {})
let onData: ((data: unknown) => void) | null = null
const client = {
subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => {
onData = cb
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn(async () => ({ ok: true, result: undefined }) as never)
} as unknown as RpcClient
vi.useFakeTimers()
try {
subscribeToDesktopNotifications(client, 'host-1')
onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
onData?.({
type: 'notification',
title: 'live-1',
body: 'b',
notificationId: 'a:1',
notificationSeq: 1
})
await vi.advanceTimersByTimeAsync(3100)
} finally {
vi.useRealTimers()
}
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
expect(titles).toContain('live-1')
})
})
@@ -0,0 +1,36 @@
import * as Notifications from 'expo-notifications'
import { Platform } from 'react-native'
// Why: OS notification-permission state, separate from the delivery pipeline in
// mobile-notifications.ts. Nothing here touches sockets, watermarks, or the
// scheduled-push registry — it only reflects what the OS currently allows.
export type NotificationPermissionState = {
granted: boolean
status: string
canAskAgain: boolean
authorizationReflectsUserChoice: boolean
}
export async function getNotificationPermissionState(): Promise<NotificationPermissionState> {
const { status, canAskAgain } = await Notifications.getPermissionsAsync()
return {
granted: status === 'granted',
status,
canAskAgain,
// Why: Android <33 has no runtime notification permission, so "granted" is capability, not user consent.
authorizationReflectsUserChoice:
status === 'granted' && (Platform.OS !== 'android' || Number(Platform.Version) >= 33)
}
}
// Why: re-read OS state every call — users can change it in Settings while Orca is backgrounded.
export async function ensureNotificationPermissions(): Promise<boolean> {
const existing = await getNotificationPermissionState()
if (existing.granted) {
return true
}
const { status } = await Notifications.requestPermissionsAsync()
return status === 'granted'
}
@@ -9,28 +9,73 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
// notification we already delivered. The in-memory seen-set is a second guard
// against double-delivery for events that arrive on both the live stream and a
// replay (e.g. a brief liveness spell before a reap).
const LAST_SEQ_STORAGE_KEY_PREFIX = 'orca:mobileNotificationsLastSeq:'
// Why (#8591): a seq is meaningless without the counter it indexes — after a
// desktop restart that counter is gone. The epoch names the counter's lifetime so
// a reconnect can tell "nothing missed" from "different counter".
//
// Why ONE key holding both, rather than a key each: they are only meaningful as a
// pair. Written separately, a process death between the two writes leaves an epoch
// from one counter beside a seq from another — a pair that looks internally valid
// on the next launch and is therefore trusted, silently cutting real notifications.
// A single JSON value cannot tear that way.
const WATERMARK_STORAGE_KEY_PREFIX = 'orca:mobileNotificationsWatermark:'
// Pre-#8591 installs wrote the seq alone. Read once to migrate; never written.
const LEGACY_SEQ_STORAGE_KEY_PREFIX = 'orca:mobileNotificationsLastSeq:'
function lastSeqStorageKey(hostId: string): string {
return LAST_SEQ_STORAGE_KEY_PREFIX + encodeURIComponent(hostId)
function watermarkStorageKey(hostId: string): string {
return WATERMARK_STORAGE_KEY_PREFIX + encodeURIComponent(hostId)
}
export async function loadLastSeenSeq(hostId: string): Promise<number> {
// A null epoch means "the counter this seq came from is unknown" — a legacy
// watermark, or nothing stored. It can never be assumed to be the live counter.
export type PersistedWatermark = { seq: number; epoch: string | null }
// `stored` is the record's existence, independent of its seq: it answers "has this
// device ever been subscribed to this host", which is what a cold open needs to tell
// a returning device from a first pairing. A seq of 0 is a real answer, not an absence.
export type LoadedWatermark = PersistedWatermark & { stored: boolean }
function coerceSeq(value: unknown): number {
const parsed = typeof value === 'number' ? value : Number(value)
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0
}
export async function loadWatermark(hostId: string): Promise<LoadedWatermark> {
try {
const raw = await AsyncStorage.getItem(lastSeqStorageKey(hostId))
const parsed = raw == null ? 0 : Number(raw)
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0
const raw = await AsyncStorage.getItem(watermarkStorageKey(hostId))
if (raw != null) {
const parsed = JSON.parse(raw) as { seq?: unknown; epoch?: unknown }
const epoch =
typeof parsed.epoch === 'string' && parsed.epoch.length > 0 ? parsed.epoch : null
return { seq: coerceSeq(parsed.seq), epoch, stored: true }
}
} catch {
return 0
// Unreadable or malformed: fall through to the legacy key rather than throw.
}
try {
const legacy = await AsyncStorage.getItem(
LEGACY_SEQ_STORAGE_KEY_PREFIX + encodeURIComponent(hostId)
)
return { seq: coerceSeq(legacy), epoch: null, stored: legacy != null }
} catch {
return { seq: 0, epoch: null, stored: false }
}
}
export async function saveLastSeenSeq(hostId: string, seq: number): Promise<void> {
if (!Number.isFinite(seq) || seq <= 0) {
return
}
export async function clearWatermark(hostId: string): Promise<void> {
// Why both keys: loadWatermark falls back to the legacy one, so removing only the
// current key would let a re-paired host resurrect a pre-#8591 seq from a counter
// lifetime that is long gone — the exact stale cut this fix removes.
await Promise.all([
AsyncStorage.removeItem(watermarkStorageKey(hostId)).catch(() => {}),
AsyncStorage.removeItem(LEGACY_SEQ_STORAGE_KEY_PREFIX + encodeURIComponent(hostId)).catch(
() => {}
)
])
}
export async function saveWatermark(hostId: string, watermark: PersistedWatermark): Promise<void> {
try {
await AsyncStorage.setItem(lastSeqStorageKey(hostId), String(seq))
await AsyncStorage.setItem(watermarkStorageKey(hostId), JSON.stringify(watermark))
} catch {
// Why: persisting the watermark is best-effort. If it fails (or lags), the
// stored value stays BELOW what we delivered, so a later cold start can
@@ -53,6 +98,7 @@ const RECENTLY_SEEN_CAP = 512
export function createSeenNotificationGuard(): {
has: (id: string) => boolean
add: (id: string) => void
clear: () => void
} {
const seen = new Set<string>()
return {
@@ -69,10 +115,224 @@ export function createSeenNotificationGuard(): {
seen.delete(first)
}
}
},
clear(): void {
seen.clear()
}
}
}
// Why (#8591): app/index.tsx tears the notification subscription down on every
// non-'connected' state and builds a fresh one on reconnect, so everything held
// in the subscription closure — the ready counter, the delivered watermark, the
// seen-set — is destroyed exactly when a reconnect needs it. Keeping it per host
// at module scope is what makes the catch-up recognise a reconnect (instead of
// mistaking it for a cold open) and keeps dedup effective across the teardown.
export type HostNotificationSession = {
// Highest desktop seq delivered for this host in this app process. Outranks
// the persisted value, which lags because saveLastSeenSeq is fire-and-forget.
lastDeliveredSeq: number
// Counter lifetime lastDeliveredSeq belongs to; null until one is known. A
// mismatch on reconnect means the desktop restarted and the watermark is void.
lastDeliveredEpoch: string | null
seen: ReturnType<typeof createSeenNotificationGuard>
// False only until the host's first subscription reaches 'ready' — a true cold open.
connectedBefore: boolean
// Why (#8591): distinguishes "this device has delivered for this host before"
// from a first-ever pairing. Only the former may catch up on a cold open — a
// brand-new pairing fetching from seq 0 would push the desktop's whole buffer
// at someone who was never subscribed for any of it.
hadStoredWatermark: boolean
// Resolves once the persisted read has landed, so the first 'ready' can wait for
// it instead of deciding catch-up against an unread watermark.
watermarkSeeded: Promise<void> | null
// Tail of the per-host delivery chain; see enqueueHostDelivery.
deliveryTail: Promise<void>
// notificationIds with a show queued or in flight on that chain; see
// shouldQueueShowForNotificationId.
queuedShowIds: Set<string>
}
const sessionsByHost = new Map<string, HostNotificationSession>()
export function getHostNotificationSession(hostId: string): HostNotificationSession {
let session = sessionsByHost.get(hostId)
if (!session) {
session = {
lastDeliveredSeq: 0,
lastDeliveredEpoch: null,
seen: createSeenNotificationGuard(),
connectedBefore: false,
hadStoredWatermark: false,
watermarkSeeded: null,
deliveryTail: Promise.resolve(),
queuedShowIds: new Set<string>()
}
sessionsByHost.set(hostId, session)
}
return session
}
/**
* Run `task` after every delivery already queued for this host, and return a
* promise for its completion.
*
* Why (#8591): the watermark is persisted by whichever delivery advances it, so
* replay and live delivery running concurrently can persist out of order. A live
* seq 11 handled while catch-up is still showing seq 6 writes watermark 11, and a
* process death before 7..10 are shown loses them permanently — the next launch
* asks the desktop for seq > 11. Serializing per host makes the watermark's
* monotonic advance mean "everything up to here was actually delivered".
*
* A rejected task does not break the chain: the tail swallows the failure so a
* single bad notification cannot wedge the host's queue forever.
*/
export function enqueueHostDelivery(
session: HostNotificationSession,
task: () => Promise<void>
): Promise<void> {
const run = session.deliveryTail.then(task)
session.deliveryTail = run.catch(() => {})
return run
}
/**
* Claim a notificationId for a queued show, returning false if one is already
* queued or in flight for it.
*
* Why this exists (#8591): showLocalNotification deduped two same-id events by
* observing that the first was still pending when the second arrived. Serializing
* deliveries removed that overlap — the first now COMPLETES before the second
* starts, so the second reads no pending state and schedules a second banner for
* the same notification. The dedup has to happen where concurrency is still
* visible, which after serialization is enqueue time rather than delivery time.
*
* Only shows are tracked. A dismiss for the same id must still run: it is the
* mechanism that retires the notification the show created.
*/
export function shouldQueueShowForNotificationId(
session: HostNotificationSession,
notificationId: string | undefined
): boolean {
if (notificationId == null) {
return true
}
if (session.queuedShowIds.has(notificationId)) {
return false
}
session.queuedShowIds.add(notificationId)
return true
}
/** Release the claim taken by shouldQueueShowForNotificationId once the show settles. */
export function releaseQueuedShowNotificationId(
session: HostNotificationSession,
notificationId: string | undefined
): void {
if (notificationId != null) {
session.queuedShowIds.delete(notificationId)
}
}
/** Test-only: drop per-host session state so each test starts from a cold open. */
export function resetHostNotificationSessionsForTests(): void {
sessionsByHost.clear()
}
// Why (#8591): the desktop's seq counter restarts at 0 every launch, so a watermark
// from a previous lifetime indexes a counter that no longer exists. Comparing it
// against the fresh counter makes `lastSeenSeq >= seq` true for everything and
// catch-up dies silently until the new process out-dispatches the old watermark.
// Adopting the new epoch means dropping the watermark with it.
export function adoptNotificationEpoch(
session: HostNotificationSession,
hostId: string,
epoch: string | undefined
): void {
if (!epoch || epoch === session.lastDeliveredEpoch) {
return
}
// Why reset on a FIRST observation too (lastDeliveredEpoch === null): a seq seeded
// from a legacy store carries no epoch, so it cannot be shown to belong to this
// counter. Keeping it would let a pre-upgrade 57 cut the new counter's 1..57 —
// the exact #8591 failure, reached through the upgrade path instead of a restart.
session.lastDeliveredSeq = 0
// Why clear `seen`: its keys are seq-derived, and terminal-bell notifications have
// no notificationId at all (they key on `seq:N` alone). Across a restart the new
// counter re-issues those same low seqs, so a stale `seq:1` would silently drop
// the new counter's first bell. The dedup window belongs to one counter lifetime.
session.seen.clear()
session.lastDeliveredEpoch = epoch
void saveWatermark(hostId, { seq: 0, epoch })
}
// Why: seed the watermark lazily so subscribe() doesn't block on an AsyncStorage read.
// Only the first subscription for a host needs it; later ones inherit the live value.
/**
* Ms the persisted read may block catch-up and live delivery before they proceed
* without it. AsyncStorage normally answers in single-digit ms; a read that has
* not landed by now is assumed wedged.
*
* Why a bound at all (#8591): every delivery awaits this promise, so a read that
* never settles silently disables notifications for the host for the whole app
* lifetime — no error, no banner, nothing to see. Proceeding unseeded is strictly
* better: the watermark stays 0, so catch-up over-fetches and the seen-set
* de-duplicates, which costs a redundant request instead of every notification.
*/
const WATERMARK_SEED_TIMEOUT_MS = 3000
function withTimeout(promise: Promise<void>, ms: number): Promise<void> {
return new Promise<void>((resolve) => {
const timer = setTimeout(resolve, ms)
void promise.then(
() => {
clearTimeout(timer)
resolve()
},
() => {
clearTimeout(timer)
resolve()
}
)
})
}
export function seedWatermarkFromStorage(session: HostNotificationSession, hostId: string): void {
if (session.watermarkSeeded) {
return
}
const seeded = loadWatermark(hostId).then(({ seq, epoch, stored }) => {
// Why the record's existence and not `seq > 0`: adoptNotificationEpoch persists
// `{seq: 0, epoch}` when it voids a watermark, so a device that HAS delivered for
// this host reloads as seq 0. Keying on the seq would read that as a first pairing
// and skip catch-up for the whole window the epoch change was meant to recover.
if (stored) {
session.hadStoredWatermark = true
}
// Why the epoch comparison: this read can land AFTER 'ready' already adopted a
// live epoch. If the stored watermark belongs to a different (older) counter,
// applying it here would silently reinstate exactly the stale cut this fixes.
// A null stored epoch is a legacy watermark of unknown provenance — it may only
// seed while no live epoch is known, and adopting one later resets it.
if (session.lastDeliveredEpoch === null || session.lastDeliveredEpoch === epoch) {
session.lastDeliveredSeq = Math.max(session.lastDeliveredSeq, seq)
if (session.lastDeliveredEpoch === null && epoch !== null) {
session.lastDeliveredEpoch = epoch
}
}
})
// The late seed still applies when it eventually lands; the timeout only stops it
// from holding delivery hostage. `seeded` never rejects into the awaiters.
session.watermarkSeeded = withTimeout(seeded, WATERMARK_SEED_TIMEOUT_MS)
}
// Why (#8591): sessions live at module scope so they survive the subscription
// teardown a reconnect performs. Nothing else drops them, so a host that is removed
// and re-paired would retain its session and up to 512 seen keys until app restart.
export function forgetHostNotificationSession(hostId: string): void {
sessionsByHost.delete(hostId)
}
// Why: key for the replay dedup guard. Uses notificationId when present, but
// disambiguates by seq so a legitimate live re-delivery of the same id at a
// NEW seq (content refresh, allowed by the existing behaviour) is NOT treated
@@ -0,0 +1,201 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import { subscribeToDesktopNotifications } from './mobile-notifications'
import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup'
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { RpcClient } from '../transport/rpc-client'
import { loadPushNotificationsEnabled } from '../storage/preferences'
vi.mock('expo-notifications', () => ({
AndroidImportance: { HIGH: 'high' },
setNotificationChannelAsync: vi.fn(),
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
scheduleNotificationAsync: vi.fn(),
dismissNotificationAsync: vi.fn()
}))
vi.mock('react-native', () => ({
Platform: { OS: 'ios', Version: 18 }
}))
// In-memory AsyncStorage so the persisted watermark survives across the
// subscribe/unsubscribe cycles this test exercises (the real device behaviour).
const storage = new Map<string, string>()
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(async (k: string) => storage.get(k) ?? null),
setItem: vi.fn(async (k: string, v: string) => {
storage.set(k, v)
})
}
}))
vi.mock('../storage/preferences', () => ({
loadPushNotificationsEnabled: vi.fn()
}))
function flushAsync(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 10)
})
}
// Models mobile/app/index.tsx:497-537: a per-host client whose notification
// subscription is torn down on any non-'connected' state and re-created from
// scratch on the next 'connected'.
function makeHostClient() {
let onData: ((data: unknown) => void) | null = null
const getMissedCalls: { lastSeenSeq: number }[] = []
const client = {
subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => {
onData = cb
return vi.fn(() => {
onData = null
})
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn(async (method: string, params: unknown = {}) => {
if (method === 'notifications.getMissedSince') {
getMissedCalls.push(params as { lastSeenSeq: number })
return { ok: true, result: { notifications: missedQueue } } as never
}
return { ok: true, result: undefined } as never
})
}
let missedQueue: unknown[] = []
return {
client: client as unknown as RpcClient,
get onData() {
return onData
},
getMissedCalls,
setMissed(events: unknown[]) {
missedQueue = events
}
}
}
describe('#8591 reconnect catch-up under the real app teardown lifecycle', () => {
beforeEach(() => {
vi.clearAllMocks()
storage.clear()
resetHostNotificationSessionsForTests()
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1')
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
vi.mocked(AsyncStorage.getItem).mockClear()
})
it('fetches missed notifications after a disconnect tears the subscription down', async () => {
const host = makeHostClient()
// ── Connected: cold open, one live notification delivered (desktop seq 7).
const unsub = subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
host.onData?.({
type: 'notification',
title: 'live',
body: 'b',
notificationId: 'agent:live',
notificationSeq: 7
})
await flushAsync()
// ── Socket drops. app/index.tsx wireUp() calls unsubNotif() on the
// non-'connected' state, destroying the subscribeToDesktopNotifications
// closure (and with it reconnectReadyCount / lastDeliveredSeq).
unsub()
await flushAsync()
// ── While disconnected the desktop dispatched seq 8 and 9.
host.setMissed([
{
type: 'notification',
title: 'missed-8',
body: 'b',
notificationId: 'agent:m8',
notificationSeq: 8
},
{
type: 'notification',
title: 'missed-9',
body: 'b',
notificationId: 'agent:m9',
notificationSeq: 9
}
])
// ── Reconnected: app re-subscribes with a FRESH closure.
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-2' })
await flushAsync()
// The user must be told about seq 8 and 9. Nothing else can deliver them:
// the desktop only fans out live, so this catch-up is the only path.
expect(host.getMissedCalls).toHaveLength(1)
expect(host.getMissedCalls[0]).toEqual({ lastSeenSeq: 7 })
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((c) => (c[0] as { content: { title: string } }).content.title)
expect(titles).toContain('missed-8')
expect(titles).toContain('missed-9')
})
it('does not re-push a live notification the catch-up replays after a teardown', async () => {
// Why: the seen-set lives on the host session precisely so it survives the teardown.
// getMissedSince cuts by seq > lastSeenSeq, but a notification delivered live in the
// brief window before the drop is still inside the desktop's retained buffer, so the
// reconnect fetch returns it again. Only the session-scoped seen-set stops a duplicate
// banner for something the user was already shown.
const host = makeHostClient()
const unsub = subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
host.onData?.({
type: 'notification',
title: 'live-7',
body: 'b',
notificationId: 'agent:seven',
notificationSeq: 7
})
await flushAsync()
unsub()
await flushAsync()
// The desktop replays seq 7 alongside the genuinely-missed seq 8.
host.setMissed([
{
type: 'notification',
title: 'live-7',
body: 'b',
notificationId: 'agent:seven',
notificationSeq: 7
},
{
type: 'notification',
title: 'missed-8',
body: 'b',
notificationId: 'agent:m8',
notificationSeq: 8
}
])
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-2' })
await flushAsync()
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((c) => (c[0] as { content: { title: string } }).content.title)
expect(titles.filter((title) => title === 'live-7')).toHaveLength(1)
expect(titles).toContain('missed-8')
})
})
@@ -0,0 +1,206 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import { subscribeToDesktopNotifications } from './mobile-notifications'
import {
adoptNotificationEpoch,
clearWatermark,
getHostNotificationSession,
resetHostNotificationSessionsForTests,
seedWatermarkFromStorage
} from './notification-reconnect-catchup'
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { RpcClient } from '../transport/rpc-client'
import { loadPushNotificationsEnabled } from '../storage/preferences'
vi.mock('expo-notifications', () => ({
AndroidImportance: { HIGH: 'high' },
setNotificationChannelAsync: vi.fn(),
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
scheduleNotificationAsync: vi.fn(),
dismissNotificationAsync: vi.fn()
}))
vi.mock('react-native', () => ({
Platform: { OS: 'ios', Version: 18 }
}))
// A storage whose reads can be held open, so a live event can be injected into the
// exact window a real cold open has: subscription up, persisted watermark not yet read.
const storage = new Map<string, string>()
let heldReads: (() => void)[] = []
let holdReads = false
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn((key: string) => {
const read = (): string | null => storage.get(key) ?? null
if (!holdReads) {
return Promise.resolve(read())
}
return new Promise<string | null>((resolve) => {
heldReads.push(() => resolve(read()))
})
}),
setItem: vi.fn(async (key: string, value: string) => {
storage.set(key, value)
}),
removeItem: vi.fn(async (key: string) => {
storage.delete(key)
})
}
}))
vi.mock('../storage/preferences', () => ({
loadPushNotificationsEnabled: vi.fn()
}))
function flushAsync(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 10)
})
}
function releaseReads(): void {
const pending = heldReads
heldReads = []
for (const resolve of pending) {
resolve()
}
}
function makeHostClient() {
let onData: ((data: unknown) => void) | null = null
const getMissedCalls: { lastSeenSeq: number; epoch?: string }[] = []
const client = {
subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => {
onData = cb
return vi.fn(() => {
onData = null
})
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn(async (method: string, params: unknown = {}) => {
if (method === 'notifications.getMissedSince') {
getMissedCalls.push(params as { lastSeenSeq: number; epoch?: string })
return { ok: true, result: { notifications: [] } } as never
}
return { ok: true, result: undefined } as never
})
}
return {
client: client as unknown as RpcClient,
get onData() {
return onData
},
getMissedCalls
}
}
const WATERMARK_KEY = 'orca:mobileNotificationsWatermark:host-1'
const LEGACY_KEY = 'orca:mobileNotificationsLastSeq:host-1'
describe('#8591 watermark seeding races a cold open', () => {
beforeEach(() => {
vi.clearAllMocks()
storage.clear()
heldReads = []
holdReads = false
resetHostNotificationSessionsForTests()
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1')
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
})
it('asks for catch-up from the persisted seq even if a live event lands first', async () => {
// The window is real: app/index.tsx subscribes immediately, and the desktop's
// 'ready' plus its first live fan-out can both beat an AsyncStorage read. If the
// live seq is allowed to advance the watermark first, getMissedSince is asked to
// start from it and the desktop cuts everything the device actually missed.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-a' }))
holdReads = true
const host = makeHostClient()
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-a' })
host.onData?.({
type: 'notification',
title: 'live-12',
body: 'b',
notificationId: 'agent:live',
notificationSeq: 12,
notificationEpoch: 'epoch-a'
})
await flushAsync()
// Nothing may be decided while the read is outstanding.
expect(host.getMissedCalls).toHaveLength(0)
releaseReads()
await flushAsync()
expect(host.getMissedCalls).toEqual([{ lastSeenSeq: 5, epoch: 'epoch-a' }])
})
it('treats a zeroed-but-present watermark as a returning device, not a first pairing', async () => {
// adoptNotificationEpoch persists {seq: 0, epoch} when it voids a watermark from a
// dead counter. That record still proves this device has been subscribed here, so a
// cold open after it must catch up — reading it as "never paired" drops the window.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 0, epoch: 'epoch-a' }))
const host = makeHostClient()
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-a' })
await flushAsync()
expect(host.getMissedCalls).toEqual([{ lastSeenSeq: 0, epoch: 'epoch-a' }])
})
it('does not catch up on a first-ever pairing', async () => {
const host = makeHostClient()
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-a' })
await flushAsync()
expect(host.getMissedCalls).toEqual([])
})
it('a seed landing after a live epoch is adopted cannot reinstate the dead watermark', async () => {
// Ordering invariant on the exported pair, not a path subscribeToDesktopNotifications
// can currently take — 'ready' awaits watermarkSeeded before adopting, so the seed
// always resolves first today. Pinned anyway because the guard is load-bearing the
// moment any caller adopts an epoch before seeding: applying a seq 40 from a counter
// that no longer exists would let getMissedSince cut the new counter's 1..40, which
// is the original #8591 loss re-entered through the seeding path.
const session = getHostNotificationSession('host-1')
adoptNotificationEpoch(session, 'host-1', 'epoch-new')
await flushAsync()
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 40, epoch: 'epoch-old' }))
seedWatermarkFromStorage(session, 'host-1')
await session.watermarkSeeded
await flushAsync()
expect(session.lastDeliveredEpoch).toBe('epoch-new')
expect(session.lastDeliveredSeq).toBe(0)
})
it('clears the legacy seq key too, so an unpaired host cannot resurrect it', async () => {
// loadWatermark falls back to the legacy key, so leaving it behind lets a re-paired
// host read a pre-#8591 seq belonging to a counter lifetime that no longer exists.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 9, epoch: 'epoch-a' }))
storage.set(LEGACY_KEY, '57')
await clearWatermark('host-1')
expect(vi.mocked(AsyncStorage.removeItem).mock.calls.map((call) => call[0])).toEqual(
expect.arrayContaining([WATERMARK_KEY, LEGACY_KEY])
)
expect(storage.has(WATERMARK_KEY)).toBe(false)
expect(storage.has(LEGACY_KEY)).toBe(false)
})
})
@@ -1,16 +1,32 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const removeHostMock = vi.hoisted(() => vi.fn())
const asyncStorage = vi.hoisted(() => ({
getItem: vi.fn(async () => null),
setItem: vi.fn(async () => undefined),
// Why removeItem is here: clearWatermark() swallows its own failures, so a mock
// missing this method turns the persisted-watermark cleanup into a caught
// TypeError — the assertion below would pass even if the call were deleted.
removeItem: vi.fn(async () => undefined)
}))
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
vi.mock('./host-store', () => ({
removeHost: (hostId: string) => removeHostMock(hostId)
}))
import { removeHostAndCloseClient } from './host-removal-lifecycle'
import {
getHostNotificationSession,
resetHostNotificationSessionsForTests
} from '../notifications/notification-reconnect-catchup'
describe('host removal lifecycle', () => {
beforeEach(() => {
removeHostMock.mockReset()
asyncStorage.removeItem.mockClear()
resetHostNotificationSessionsForTests()
})
it('closes the client only after metadata removal commits', async () => {
@@ -39,4 +55,37 @@ describe('host removal lifecycle', () => {
)
expect(closeHostClient).not.toHaveBeenCalled()
})
it('retires the notification session so a removed host leaves nothing behind', async () => {
// Round-1 review finding: the session lives at module scope (it must survive the
// subscription teardown a reconnect performs), so removal is the only thing that
// can retire it. Left behind, each remove/re-pair cycle strands a session plus up
// to 512 seen keys, and a re-paired host inherits a watermark it never earned.
removeHostMock.mockResolvedValue(undefined)
const session = getHostNotificationSession('host-1')
session.lastDeliveredSeq = 42
session.lastDeliveredEpoch = 'epoch-A'
await removeHostAndCloseClient('host-1', vi.fn())
// A fresh session for the same id — not the retained one.
const afterRemoval = getHostNotificationSession('host-1')
expect(afterRemoval).not.toBe(session)
expect(afterRemoval.lastDeliveredSeq).toBe(0)
expect(afterRemoval.lastDeliveredEpoch).toBeNull()
})
it('erases the persisted watermark, not just the in-memory session', async () => {
// Why separately from the test above: the session is process-local, the
// watermark is not. Retiring only the session lets a re-pair of the same host
// read the old seq off disk and resume against a counter it never saw — the
// catch-up would then start above the real cut and drop everything below it.
removeHostMock.mockResolvedValue(undefined)
await removeHostAndCloseClient('host-1', vi.fn())
// clearWatermark is fire-and-forget; let its microtask land.
await Promise.resolve()
expect(asyncStorage.removeItem).toHaveBeenCalledWith('orca:mobileNotificationsWatermark:host-1')
})
})
@@ -1,3 +1,7 @@
import {
clearWatermark,
forgetHostNotificationSession
} from '../notifications/notification-reconnect-catchup'
import { removeHost } from './host-store'
export async function removeHostAndCloseClient(
@@ -8,4 +12,9 @@ export async function removeHostAndCloseClient(
// storage failure; closing immediately after success prevents socket leaks.
await removeHost(hostId)
closeHostClient(hostId)
// Why: the notification session outlives the socket by design (it must survive
// reconnects), so removal is the only thing that can retire it. Left behind, a
// re-pair of the same host would inherit a watermark for a counter it never saw.
forgetHostNotificationSession(hostId)
void clearWatermark(hostId)
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.160-rc.0",
"version": "1.4.160-rc.2",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
+1 -1
View File
@@ -1,4 +1,4 @@
import { RuntimeClientError } from './runtime-client'
import { RuntimeClientError } from './runtime/types'
import { unknownCommandData, unknownFlagData } from './command-suggestion'
export type ParsedArgs = {
+1 -1
View File
@@ -1,5 +1,5 @@
import type { RuntimeClient } from './runtime-client'
import { RuntimeClientError } from './runtime-client'
import { RuntimeClientError } from './runtime/types'
import { HANDLER_GROUPS, type HandlerGroup } from './handler-group-manifest'
export type HandlerContext = {
+1 -1
View File
@@ -1,4 +1,4 @@
import { RuntimeClientError } from './runtime-client'
import { RuntimeClientError } from './runtime/types'
import { REPEATED_FLAG_SEPARATOR } from './args'
export function getRequiredStringFlag(flags: Map<string, string | boolean>, name: string): string {
+1 -1
View File
@@ -2,7 +2,7 @@ import type { CliStatusResult } from '../shared/runtime-types'
import { computerUseErrorRecoveryData } from '../shared/computer-use-error-recovery'
import { prepareComputerCliJsonResult } from './computer-format'
import type { RuntimeRpcFailure, RuntimeRpcSuccess } from './runtime-client'
import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client'
import { RuntimeClientError, RuntimeRpcFailureError } from './runtime/types'
export {
formatBrowserProfileList,
@@ -2,36 +2,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const callMock = vi.fn()
vi.mock('../runtime-client', () => {
vi.mock('../runtime-client', async () => {
class RuntimeClient {
call = callMock
getCliStatus = vi.fn()
openOrca = vi.fn()
}
class RuntimeClientError extends Error {
readonly code: string
constructor(code: string, message: string) {
super(message)
this.code = code
}
}
class RuntimeRpcFailureError extends RuntimeClientError {
readonly response: unknown
constructor(response: unknown) {
super('runtime_error', 'runtime_error')
this.response = response
}
}
return {
RuntimeClient,
RuntimeClientError,
RuntimeRpcFailureError
}
// Why: re-export the REAL error classes rather than redefining them. format.ts
// narrows with `instanceof` against ./runtime/types, so a look-alike class
// here would make every CLI error fall through to the generic `runtime_error`
// shape — mirroring the barrel keeps the mock faithful to production.
const { RuntimeClientError, RuntimeRpcFailureError } = await import('../runtime/types.js')
return { RuntimeClient, RuntimeClientError, RuntimeRpcFailureError }
})
import { main } from '../index'
+6 -24
View File
@@ -2,36 +2,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const callMock = vi.fn()
vi.mock('../runtime-client', () => {
vi.mock('../runtime-client', async () => {
class RuntimeClient {
call = callMock
getCliStatus = vi.fn()
openOrca = vi.fn()
}
class RuntimeClientError extends Error {
readonly code: string
constructor(code: string, message: string) {
super(message)
this.code = code
}
}
class RuntimeRpcFailureError extends RuntimeClientError {
readonly response: unknown
constructor(response: unknown) {
super('runtime_error', 'runtime_error')
this.response = response
}
}
return {
RuntimeClient,
RuntimeClientError,
RuntimeRpcFailureError
}
// Why: re-export the REAL error classes; format.ts narrows with `instanceof`
// against ./runtime/types, so a look-alike would collapse every CLI error
// code into the generic `runtime_error` shape.
const { RuntimeClientError, RuntimeRpcFailureError } = await import('../runtime/types.js')
return { RuntimeClient, RuntimeClientError, RuntimeRpcFailureError }
})
import { main } from '../index'
+6 -24
View File
@@ -6,7 +6,7 @@ const { callMock, remoteMock } = vi.hoisted(() => ({
remoteMock: vi.fn(() => false)
}))
vi.mock('../runtime-client', () => {
vi.mock('../runtime-client', async () => {
class RuntimeClient {
readonly isRemote: boolean
call = callMock
@@ -18,29 +18,11 @@ vi.mock('../runtime-client', () => {
}
}
class RuntimeClientError extends Error {
readonly code: string
constructor(code: string, message: string) {
super(message)
this.code = code
}
}
class RuntimeRpcFailureError extends RuntimeClientError {
readonly response: unknown
constructor(response: unknown) {
super('runtime_error', 'runtime_error')
this.response = response
}
}
return {
RuntimeClient,
RuntimeClientError,
RuntimeRpcFailureError
}
// Why: re-export the REAL error classes; format.ts narrows with `instanceof`
// against ./runtime/types, so a look-alike would collapse every CLI error
// code into the generic `runtime_error` shape.
const { RuntimeClientError, RuntimeRpcFailureError } = await import('../runtime/types.js')
return { RuntimeClient, RuntimeClientError, RuntimeRpcFailureError }
})
import { main } from '../index'
+5 -18
View File
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const callMock = vi.fn()
vi.mock('../runtime-client', () => {
vi.mock('../runtime-client', async () => {
class RuntimeClient {
readonly isRemote: boolean
call = callMock
@@ -19,23 +19,10 @@ vi.mock('../runtime-client', () => {
}
}
class RuntimeClientError extends Error {
readonly code: string
constructor(code: string, message: string) {
super(message)
this.code = code
}
}
class RuntimeRpcFailureError extends RuntimeClientError {
readonly response: unknown
constructor(response: unknown) {
super('runtime_error', 'runtime_error')
this.response = response
}
}
// Why: re-export the REAL error classes; format.ts narrows with `instanceof`
// against ./runtime/types, so a look-alike would collapse every CLI error
// code into the generic `runtime_error` shape.
const { RuntimeClientError, RuntimeRpcFailureError } = await import('../runtime/types.js')
return {
RuntimeClient,
+7 -21
View File
@@ -22,7 +22,13 @@ const {
spawnMock: vi.fn()
}))
vi.mock('./runtime-client', () => {
vi.mock('./runtime-client', async () => {
// Why: re-export the REAL error classes rather than redefining them. format.ts
// narrows with `instanceof` against ./runtime/types, so a look-alike class
// here would make every CLI error fall through to the generic `runtime_error`
// shape — mirroring the barrel keeps the mock faithful to production.
const { RuntimeClientError, RuntimeRpcFailureError } = await import('./runtime/types.js')
class RuntimeClient {
readonly isRemote: boolean
call = callMock
@@ -52,26 +58,6 @@ vi.mock('./runtime-client', () => {
}
}
class RuntimeClientError extends Error {
readonly code: string
readonly data?: unknown
constructor(code: string, message: string, data?: unknown) {
super(message)
this.code = code
this.data = data
}
}
class RuntimeRpcFailureError extends RuntimeClientError {
readonly response: unknown
constructor(response: unknown) {
super('runtime_error', 'runtime_error')
this.response = response
}
}
return {
RuntimeClient,
RuntimeClientError,
+14 -4
View File
@@ -11,7 +11,7 @@ import {
import { dispatch } from './dispatch'
import { reportCliError } from './format'
import { printHelp } from './help'
import { RuntimeClient } from './runtime-client'
import type { RuntimeClient } from './runtime-client'
import { COMMAND_SPECS } from './specs'
export { COMMAND_SPECS } from './specs'
@@ -29,6 +29,15 @@ function shouldIgnoreRemoteSelection(commandPath: string[]): boolean {
)
}
// Why: the RuntimeClient graph is 153 of the CLI's 199 eager modules (zod via
// shared/pairing, ws + tweetnacl via websocket-transport). Loading it here
// rather than at module scope means --help, `help <cmd>`, and command/flag
// errors — which all return before this call — never pay for it. Awaited
// before dispatch so `ctx.client` stays a synchronous getter.
async function loadRuntimeClientClass(): Promise<typeof RuntimeClient> {
return (await import('./runtime-client.js')).RuntimeClient
}
// Why: the SSH relay bridge executes this CLI on the Orca host while the
// caller's shell cwd lives on the remote machine (which cannot be chdir'd
// into). ORCA_CLI_CWD carries that remote cwd so cwd-based selectors like
@@ -74,6 +83,7 @@ export async function main(
// lookup so users do not get misleading "Orca is not running" failures for
// simple command typos or unsupported flags.
validateCommandAndFlags(COMMAND_SPECS, parsed)
const RuntimeClientClass = await loadRuntimeClientClass()
const ignoreRemoteSelection = shouldIgnoreRemoteSelection(parsed.commandPath)
const pairingCode = ignoreRemoteSelection ? null : parsed.flags.get('pairing-code')
const environmentSelector = ignoreRemoteSelection ? null : parsed.flags.get('environment')
@@ -86,7 +96,7 @@ export async function main(
flags: parsed.flags,
// Why: local-only handlers must not resolve runtime metadata just to dispatch.
get client() {
client ??= new RuntimeClient(
client ??= new RuntimeClientClass(
undefined,
undefined,
typeof pairingCode === 'string' ? pairingCode : ignoreRemoteSelection ? null : undefined,
@@ -111,7 +121,7 @@ async function runClaudeTeams(argv: string[], cwd: string): Promise<void> {
try {
// Why: everything after `orca claude-teams` belongs to Claude Code, not
// Orca's own flag parser, so new Claude flags work without Orca changes.
const client = new RuntimeClient(undefined, undefined, null, null)
const client = new (await loadRuntimeClientClass())(undefined, undefined, null, null)
await dispatch(['claude-teams'], {
flags: new Map(),
client,
@@ -127,7 +137,7 @@ async function runClaudeTeams(argv: string[], cwd: string): Promise<void> {
async function runAgentTeamsTmuxShim(argv: string[]): Promise<void> {
try {
const client = new RuntimeClient(undefined, 10_000)
const client = new (await loadRuntimeClientClass())(undefined, 10_000)
const response = await client.call<{
tmux: { stdout: string; stderr: string; exitCode: number }
}>(
+205
View File
@@ -0,0 +1,205 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
const { constructorArgsMock, callMock, getCliStatusMock } = vi.hoisted(() => ({
constructorArgsMock: vi.fn(),
callMock: vi.fn(),
getCliStatusMock: vi.fn()
}))
// Why: `main` reaches RuntimeClient through `await import('./runtime-client.js')`
// now. Mocking the same specifier the eager import used proves the dynamic
// import still resolves to the module the 10 existing vi.mock suites target.
vi.mock('./runtime-client', () => {
class RuntimeClient {
call = callMock
getCliStatus = getCliStatusMock
openOrca = vi.fn()
constructor(...args: unknown[]) {
constructorArgsMock(...args)
}
}
return { RuntimeClient }
})
import { main } from './index'
import * as dispatchModule from './dispatch'
const CLI_DIR = __dirname
describe('RuntimeClient module-graph deferral', () => {
let logSpy: ReturnType<typeof vi.spyOn>
let errorSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
constructorArgsMock.mockClear()
callMock.mockReset()
getCliStatusMock.mockReset()
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
logSpy.mockRestore()
errorSpy.mockRestore()
vi.unstubAllEnvs()
process.exitCode = 0
})
// Why: the whole point of the change. These five modules load on EVERY
// invocation, so a value-import of the barrel from any of them drags the
// RuntimeClient graph (zod, ws, tweetnacl) back onto the --help path.
it.each(['args.ts', 'flags.ts', 'dispatch.ts', 'format.ts', 'selectors.ts'])(
'%s imports error classes from ./runtime/types, not the barrel',
(file) => {
const source = readFileSync(join(CLI_DIR, file), 'utf8')
const valueImports = source
.split('\n')
.filter((line) => line.startsWith('import ') && line.includes("'./runtime-client'"))
for (const line of valueImports) {
expect(line, `${file}: "${line}" must be type-only`).toMatch(/^import type /)
}
expect(source).toContain("} from './runtime/types'")
}
)
it('index.ts has no eager value-import of the runtime client', () => {
const source = readFileSync(join(CLI_DIR, 'index.ts'), 'utf8')
expect(source).toContain("import type { RuntimeClient } from './runtime-client'")
expect(source).not.toMatch(/^import \{[^}]*RuntimeClient[^}]*\} from '\.\/runtime-client'/m)
expect(source).toContain("await import('./runtime-client.js')")
})
it('constructs no client for --help', async () => {
await main(['--help'], '/tmp/repo')
expect(constructorArgsMock).not.toHaveBeenCalled()
})
it('constructs no client for an unknown flag', async () => {
await main(['worktree', 'list', '--nope'], '/tmp/repo')
expect(process.exitCode).toBe(1)
expect(constructorArgsMock).not.toHaveBeenCalled()
})
// Why: `agent hooks on|off` are the only commands that both sit in a
// suppressed group and touch ctx.client, and they rewrite the real ~/.claude
// hook config — so the byte-for-byte equivalence script cannot invoke them.
// Assert the constructor arguments directly instead: `null` (not `undefined`)
// is what stops the ORCA_* env fallback re-activating for local-only groups.
//
// `constructs` is declared per case and asserted BEFORE the args, because
// only `agent hooks off` reads ctx.client. Looping over `mock.calls` alone
// would pass vacuously for the other four, and would keep passing if the one
// case that carries the null-vs-undefined coverage stopped constructing at
// all. The zero rows are not filler: they assert the deferral itself — a
// local-only group must reach its handler without building a client.
const SUPPRESSED_GROUPS: [name: string, argv: string[], constructs: number][] = [
['agent', ['agent', 'hooks', 'off'], 1],
['environment', ['environment', 'list'], 0],
['serve', ['serve'], 0],
['vm', ['vm', 'recipe', 'doctor'], 0],
['agent-context', ['agent-context'], 0]
]
it.each(SUPPRESSED_GROUPS)(
'constructs exactly %s expected clients, with null remote selection',
async (_name, argv, constructs) => {
vi.stubEnv('ORCA_PAIRING_CODE', 'pairing-code')
vi.stubEnv('ORCA_ENVIRONMENT', 'some-environment')
getCliStatusMock.mockResolvedValue({ result: { runtime: { reachable: false } } })
await main(argv, '/tmp/repo')
const calls = constructorArgsMock.mock.calls
expect(calls.length, `${argv.join(' ')} client constructions`).toBe(constructs)
for (const call of calls) {
expect(call[2], `${argv.join(' ')} pairing code`).toBeNull()
expect(call[3], `${argv.join(' ')} environment`).toBeNull()
}
}
)
// Why: four of the five groups above never read ctx.client, so they can only
// assert that no client is built — not that suppression forwards `null`.
// Stub dispatch and read the getter directly so every group asserts the
// constructor arguments unconditionally, exactly once.
it.each(SUPPRESSED_GROUPS.map(([name, argv]) => [name, argv] as const))(
'forwards null remote selection to the client %s would build',
async (_name, argv) => {
vi.stubEnv('ORCA_PAIRING_CODE', 'pairing-code')
vi.stubEnv('ORCA_ENVIRONMENT', 'some-environment')
const dispatchSpy = vi.spyOn(dispatchModule, 'dispatch').mockResolvedValue(undefined)
try {
await main(argv, '/tmp/repo')
const ctx = dispatchSpy.mock.calls.at(-1)?.[1]
expect(dispatchSpy, `${argv.join(' ')} reached dispatch`).toHaveBeenCalledTimes(1)
void ctx?.client
expect(constructorArgsMock, `${argv.join(' ')} constructions`).toHaveBeenCalledTimes(1)
const [, , pairingCode, environment] = constructorArgsMock.mock.calls[0]
expect(pairingCode, `${argv.join(' ')} pairing code`).toBeNull()
expect(environment, `${argv.join(' ')} environment`).toBeNull()
} finally {
dispatchSpy.mockRestore()
}
}
)
// Why: the mirror — the same stubbed-dispatch probe must show `undefined`
// (not `null`) for a non-suppressed group, or the assertion above would pass
// for a build that suppressed EVERY command's env fallback.
it('forwards undefined remote selection for a non-suppressed group', async () => {
vi.stubEnv('ORCA_PAIRING_CODE', 'pairing-code')
const dispatchSpy = vi.spyOn(dispatchModule, 'dispatch').mockResolvedValue(undefined)
try {
await main(['worktree', 'list'], '/tmp/repo')
void dispatchSpy.mock.calls.at(-1)?.[1]?.client
expect(constructorArgsMock).toHaveBeenCalledTimes(1)
const [, , pairingCode, environment] = constructorArgsMock.mock.calls[0]
expect(pairingCode).toBeUndefined()
expect(environment).toBeUndefined()
} finally {
dispatchSpy.mockRestore()
}
})
// Why: the mirror of the above — for every other command the env fallback
// MUST stay live, which the RuntimeClient default parameters implement. That
// only works if `undefined` is forwarded.
it('forwards undefined for non-suppressed commands so the env fallback applies', async () => {
callMock.mockResolvedValue({ result: { worktrees: [] } })
await main(['worktree', 'list', '--json'], '/tmp/repo')
expect(constructorArgsMock).toHaveBeenCalled()
const [, , pairingCode, environment] = constructorArgsMock.mock.calls[0]
expect(pairingCode).toBeUndefined()
expect(environment).toBeUndefined()
})
it('forwards explicit --pairing-code and --environment values verbatim', async () => {
callMock.mockResolvedValue({ result: { worktrees: [] } })
await main(['worktree', 'list', '--pairing-code', 'code-1', '--json'], '/tmp/repo')
expect(constructorArgsMock.mock.calls[0][2]).toBe('code-1')
expect(constructorArgsMock.mock.calls[0][3]).toBeUndefined()
constructorArgsMock.mockClear()
await main(['worktree', 'list', '--environment', 'env-1', '--json'], '/tmp/repo')
expect(constructorArgsMock.mock.calls[0][2]).toBeUndefined()
expect(constructorArgsMock.mock.calls[0][3]).toBe('env-1')
})
// Why: the getter is memoised; a dynamic import inside it would have made it
// async and changed every handler signature.
it('reuses one client instance across repeated ctx.client reads', async () => {
callMock.mockResolvedValue({ result: { worktrees: [] } })
await main(['worktree', 'list', '--json'], '/tmp/repo')
expect(constructorArgsMock).toHaveBeenCalledTimes(1)
})
})
+10 -1
View File
@@ -11,7 +11,7 @@ import { getDefaultUserDataPath, readMetadata } from './metadata'
import { getCliStatus, resolveDesktopWindowStatus } from './status'
import { sendRequest } from './transport'
import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types'
import { sendWebSocketRequest } from './websocket-transport'
import type { sendWebSocketRequest } from './websocket-transport'
import { markEnvironmentUsed, resolveEnvironmentPairingOffer } from './environments'
import { describeRuntimeCompatBlock, evaluateRuntimeCompat } from '../../shared/protocol-compat'
import {
@@ -29,6 +29,13 @@ import {
// keepalive window. See design doc §3.1.
const LONG_POLL_CLIENT_GRACE_MS = 10_000
// Why: ws + tweetnacl + the remote-runtime frame stack only matter once a
// request actually goes over a pairing offer, which local CLI calls never do.
// Both call sites already await this, so deferring the load changes no ordering.
async function loadSendWebSocketRequest(): Promise<typeof sendWebSocketRequest> {
return (await import('./websocket-transport.js')).sendWebSocketRequest
}
export class RuntimeClient {
private readonly userDataPath: string
private readonly requestTimeoutMs: number
@@ -80,6 +87,7 @@ export class RuntimeClient {
if (method !== 'status.get') {
await this.ensureRemoteRuntimeCompatible(effectiveTimeoutMs)
}
const sendWebSocketRequest = await loadSendWebSocketRequest()
let response
try {
response = await sendWebSocketRequest<TResult>(
@@ -179,6 +187,7 @@ export class RuntimeClient {
if (!this.remotePairing || this.remoteCompatChecked) {
return
}
const sendWebSocketRequest = await loadSendWebSocketRequest()
const response = await sendWebSocketRequest<RuntimeStatus>(
this.remotePairing,
'status.get',
+1 -1
View File
@@ -6,7 +6,7 @@ import type {
} from '../shared/runtime-types'
import { isPathInsideOrEqual } from '../shared/cross-platform-path'
import type { RuntimeClient } from './runtime-client'
import { RuntimeClientError } from './runtime-client'
import { RuntimeClientError } from './runtime/types'
import { getOptionalStringFlag, getRequiredStringFlag } from './flags'
export type BrowserCliTarget = {
@@ -39,6 +39,29 @@ describe('BackgroundTransientFactRelay', () => {
expect(emitted).toEqual([{ sessionId: 's1', fact: { kind: 'command-finished', exitCode: 0 } }])
})
it('preserves a provisional 2031 subscribe when scan authority moves to the daemon', () => {
const { relay, emitted } = createRelay()
relay.onSessionData('s1', '\x1b[?2031h\x1b[?')
relay.setSessionBackground('s1', true)
relay.seedSessionScanState('s1', '\x1b[?')
relay.onSessionData('s1', '25h')
expect(emitted).toEqual([{ sessionId: 's1', fact: { kind: '2031-subscribe' } }])
})
it('exposes a provisional 2031 subscribe when scan authority returns to main', () => {
const { relay } = createRelay()
relay.setSessionBackground('s1', true)
relay.seedSessionScanState('s1', '')
relay.onSessionData('s1', '\x1b[?2031h\x1b[?')
relay.setSessionBackground('s1', false)
expect(relay.getMode2031ReplyScanState('s1')).toEqual({
tail: '\x1b[?',
pendingSubscribe: true
})
})
it('stops emitting after un-background and reports the toggle as a state change', () => {
const { relay, emitted } = createRelay()
expect(relay.setSessionBackground('s1', true)).toBe(true)
@@ -19,6 +19,11 @@ import {
createTerminalTitleTracker,
type TerminalTitleTracker
} from '../../shared/terminal-output-side-effects'
import {
INITIAL_MODE_2031_REPLY_SCAN_STATE,
scanMode2031ReplyDecision,
type Mode2031ReplyScanState
} from '../../shared/terminal-color-scheme-protocol'
import type { DaemonTransientFact } from './types'
// Kill switch for the whole background keep-tail mechanism (thinning +
@@ -27,6 +32,8 @@ export const BACKGROUND_STREAM_DROP_ENABLED = process.env.ORCA_DAEMON_BACKGROUND
export class BackgroundTransientFactRelay {
private trackersBySessionId = new Map<string, TerminalTitleTracker>()
// Why: shadow foreground bytes so a provisional subscribe survives either scan-authority handoff.
private mode2031ReplyScanStateBySessionId = new Map<string, Mode2031ReplyScanState>()
private emitFact: (sessionId: string, fact: DaemonTransientFact) => void
constructor(emitFact: (sessionId: string, fact: DaemonTransientFact) => void) {
@@ -59,7 +66,8 @@ export class BackgroundTransientFactRelay {
// PR-link dedup memory, so a link re-printed across toggles can
// re-fire — consumers treat pr-link as a latest-association update.
onPrLink: (link) => this.emitFact(sessionId, { kind: 'pr-link', link }),
onMode2031Subscribe: () => this.emitFact(sessionId, { kind: '2031-subscribe' })
onMode2031Subscribe: () => this.emitFact(sessionId, { kind: '2031-subscribe' }),
onMode2031Unsubscribe: () => this.emitFact(sessionId, { kind: '2031-unsubscribe' })
})
)
} else {
@@ -73,30 +81,63 @@ export class BackgroundTransientFactRelay {
* background toggle neither mints a phantom bell nor loses its fact. A
* partial tail contains no complete sequence, so this can never fire. */
seedSessionScanState(sessionId: string, partialEscapeTailAnsi: string): void {
if (partialEscapeTailAnsi.length > 0) {
this.trackersBySessionId
.get(sessionId)
?.handleChunk(partialEscapeTailAnsi, { titleScanData: '' })
let mode2031State = this.mode2031ReplyScanStateBySessionId.get(sessionId)
if (!mode2031State && partialEscapeTailAnsi.length > 0) {
mode2031State = scanMode2031ReplyDecision(
INITIAL_MODE_2031_REPLY_SCAN_STATE,
partialEscapeTailAnsi
).state
if (mode2031State.tail.length > 0) {
this.mode2031ReplyScanStateBySessionId.set(sessionId, mode2031State)
}
}
mode2031State ??= INITIAL_MODE_2031_REPLY_SCAN_STATE
const scanSeedAnsi = mode2031State.tail || partialEscapeTailAnsi
if (scanSeedAnsi.length > 0) {
this.trackersBySessionId.get(sessionId)?.handleChunk(scanSeedAnsi, {
titleScanData: '',
mode2031PendingSubscribe: mode2031State.pendingSubscribe
})
}
}
/** Feed one raw chunk, in byte order, BEFORE it is enqueued for delivery —
* facts must be captured even when the chunk is later keep-tail dropped. */
onSessionData(sessionId: string, data: string): void {
const previousMode2031State = this.mode2031ReplyScanStateBySessionId.get(sessionId)
if (previousMode2031State || data.includes('\x1b') || data.includes('\x9b')) {
const mode2031Result = scanMode2031ReplyDecision(
previousMode2031State ?? INITIAL_MODE_2031_REPLY_SCAN_STATE,
data
)
if (mode2031Result.state.tail.length > 0 || mode2031Result.state.pendingSubscribe) {
this.mode2031ReplyScanStateBySessionId.set(sessionId, mode2031Result.state)
} else {
this.mode2031ReplyScanStateBySessionId.delete(sessionId)
}
}
// titleScanData:'' skips title extraction (titles stay main-authoritative)
// and keeps the stale-working-title timer permanently unarmed — only the
// four transient scanners consume the chunk.
this.trackersBySessionId.get(sessionId)?.handleChunk(data, { titleScanData: '' })
}
getMode2031ReplyScanState(sessionId: string): Mode2031ReplyScanState {
return (
this.mode2031ReplyScanStateBySessionId.get(sessionId) ?? INITIAL_MODE_2031_REPLY_SCAN_STATE
)
}
onSessionExit(sessionId: string): void {
this.disposeTracker(sessionId)
this.mode2031ReplyScanStateBySessionId.delete(sessionId)
}
dispose(): void {
for (const sessionId of Array.from(this.trackersBySessionId.keys())) {
this.disposeTracker(sessionId)
}
this.mode2031ReplyScanStateBySessionId.clear()
}
private disposeTracker(sessionId: string): void {
@@ -3,12 +3,14 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types'
describe('foreground-confirmation daemon protocol', () => {
it('rejects daemons from before the fresh-confirmation RPC', () => {
expect(PROTOCOL_VERSION).toBe(28)
expect(PROTOCOL_VERSION).toBe(29)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(24)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(25)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(26)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(27)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(28)
})
})
@@ -4,19 +4,33 @@ import {
AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION,
COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
GET_FOREGROUND_PROCESS_PROTOCOL_VERSION,
MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION,
PREVIOUS_DAEMON_PROTOCOL_VERSIONS,
PROTOCOL_VERSION
PROTOCOL_VERSION,
supportsMode2031UnsubscribeFact
} from './daemon-protocol-version'
describe('daemon protocol version', () => {
it('ships preflight-cache replacement after completion inspection', () => {
expect(PROTOCOL_VERSION).toBe(28)
it('ships the 2031-unsubscribe fact after preflight-cache replacement', () => {
expect(PROTOCOL_VERSION).toBe(29)
expect(MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION).toBe(29)
expect(COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION).toBe(27)
expect(GET_FOREGROUND_PROCESS_PROTOCOL_VERSION).toBe(11)
expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26)
expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual(
Array.from({ length: 27 }, (_, index) => index + 1)
Array.from({ length: 28 }, (_, index) => index + 1)
)
})
it('withholds 2031-unsubscribe support from every preserved older daemon', () => {
// Why (#9993): v28 is what ships today, so a v28 daemon preserved across an app
// update is the live hazard — it emits '2031-subscribe' with no way to retract it.
// The boundary must sit at 29, not merely "recent enough".
expect(supportsMode2031UnsubscribeFact(PROTOCOL_VERSION)).toBe(true)
expect(supportsMode2031UnsubscribeFact(28)).toBe(false)
for (const version of PREVIOUS_DAEMON_PROTOCOL_VERSIONS) {
expect(supportsMode2031UnsubscribeFact(version)).toBe(false)
}
})
})
+19 -3
View File
@@ -1,6 +1,6 @@
// Why: daemons survive app updates, so wire behavior must be version-gated.
// v28 replaces v26/v27 daemons that can retain permanent macOS preflight rejections (#9756).
export const PROTOCOL_VERSION = 28
// v29 emits '2031-unsubscribe' transient facts; v20-28 emit only '2031-subscribe' (#9993).
export const PROTOCOL_VERSION = 29
export const COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION = 27
export const GET_FOREGROUND_PROCESS_PROTOCOL_VERSION = 11
export const PTY_STARTUP_INGRESS_PROTOCOL_VERSION = 25
@@ -8,10 +8,26 @@ export const AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION = 26
export const AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION = 26
export const GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION = 22
export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24
// Why (#9993): a gate-managed pane's bytes never reach the renderer, so main's
// transient facts are the only thing that can retire a 2031 subscription for it.
// Daemons before this version emit '2031-subscribe' but have no unsubscribe fact
// at all, so a TUI exiting while hidden would leave the subscription registered
// forever and the next theme flip would inject CSI 997 into whatever replaced it.
// Scan authority moves to the daemon only while a session is backgrounded, so the
// gate lives on backgrounding itself (setPtyBackgrounded): a pre-v29 daemon is
// never asked to thin, and main's scanner — which emits BOTH facts — stays
// authoritative over the whole stream. Filtering the subscribe fact alone would
// not help, because the visible-era subscription is registered by main.
export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27,
28
] as const
export function supportsPtyStartupIngress(protocolVersion: number): boolean {
return protocolVersion >= PTY_STARTUP_INGRESS_PROTOCOL_VERSION
}
export function supportsMode2031UnsubscribeFact(protocolVersion: number): boolean {
return protocolVersion >= MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION
}
+276
View File
@@ -765,6 +765,177 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
})
})
describe('mode 2031 fact compatibility (#9993)', () => {
let onEventSpy: ReturnType<typeof vi.spyOn>
// Why these tests exist: daemons survive app updates, so a NEW desktop can be
// driving a PRESERVED older daemon. Those daemons emit '2031-subscribe' but have
// no unsubscribe fact at all. For a gate-managed pane the renderer never sees the
// bytes, so main's facts are the only thing that can retire the subscription —
// trusting a subscribe that can never be retracted leaves it live forever, and the
// next theme flip injects CSI 997 into whatever shell replaced the exited TUI.
function captureForwardedFacts(target: DaemonPtyAdapter): {
kinds: () => string[]
emit: (fact: { kind: string }) => void
} {
const forwarded: string[] = []
target.onBackgroundStreamEvent((payload) => {
if (payload.kind === 'transientFact') {
forwarded.push((payload.fact as { kind: string }).kind)
}
})
const listeners: ((event: unknown) => void)[] = []
onEventSpy = vi.spyOn(DaemonClient.prototype, 'onEvent').mockImplementation((listener) => {
listeners.push(listener)
return () => {}
})
return {
kinds: () => forwarded,
emit: (fact) => {
expect(listeners.length).toBeGreaterThan(0)
for (const listener of listeners) {
// `type: 'event'` is the envelope the routing switch requires.
listener({
type: 'event',
event: 'transientFact',
sessionId: 'session-1',
payload: fact
})
}
}
}
}
it('drops a pre-v29 daemon 2031-subscribe it could never retract', () => {
// v28 is the version shipping today, so this is the live upgrade hazard: a v28
// daemon preserved across an app update, still holding real sessions.
const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 28 })
try {
const captured = captureForwardedFacts(legacy)
// Force a fresh wire-up so the spy above is the listener the adapter installs.
legacy['removeEventListener'] = null
legacy['setupEventRouting']()
captured.emit({ kind: '2031-subscribe' })
captured.emit({ kind: 'bell' })
// The unretractable subscribe is withheld; unrelated facts still flow, so the
// gate is narrow rather than "ignore this daemon's facts".
expect(captured.kinds()).toEqual(['bell'])
} finally {
legacy.dispose()
onEventSpy.mockRestore()
}
})
it('never delegates scan authority to a v28 daemon, so a hidden withdrawal is still seen', () => {
// The scenario the fact filter alone does NOT cover, and the reason the gate sits
// on backgrounding: while the pane is VISIBLE, main's own scanner registers the
// 2031 subscribe (bytes transit main either way). Only backgrounding hands scan
// authority to the daemon. If a v28 daemon were allowed to take it, the TUI could
// exit while hidden with no party able to emit the withdrawal — #9993 via upgrade.
const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify')
const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 28 })
try {
legacy.setPtyBackgrounded('v28-session', true)
expect(notifySpy).toHaveBeenCalledWith('setSessionBackground', {
sessionId: 'v28-session',
background: false
})
} finally {
legacy.dispose()
notifySpy.mockRestore()
}
})
it('delegates scan authority to a v29 daemon, which can retract', () => {
const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify')
const current = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 29 })
try {
current.setPtyBackgrounded('v29-session', true)
expect(notifySpy).toHaveBeenCalledWith('setSessionBackground', {
sessionId: 'v29-session',
background: true
})
} finally {
current.dispose()
notifySpy.mockRestore()
}
})
it('forwards a provisional subscribe with the foreground handoff marker', () => {
const current = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 29 })
const forwarded: unknown[] = []
current.onBackgroundStreamEvent((payload) => forwarded.push(payload))
const listeners: ((event: unknown) => void)[] = []
onEventSpy = vi.spyOn(DaemonClient.prototype, 'onEvent').mockImplementation((listener) => {
listeners.push(listener)
return () => {}
})
try {
current['removeEventListener'] = null
current['setupEventRouting']()
for (const listener of listeners) {
listener({
type: 'event',
event: 'sessionBackgroundMarker',
sessionId: 'session-1',
payload: {
background: false,
scanSeedAnsi: '\x1b[?',
mode2031PendingSubscribe: true
}
})
}
expect(forwarded).toEqual([
{
id: 'session-1',
kind: 'backgroundMarker',
background: false,
scanSeedAnsi: '\x1b[?',
mode2031PendingSubscribe: true
}
])
} finally {
current.dispose()
onEventSpy.mockRestore()
}
})
it('forwards a v28 unsubscribe, which can only retire state main registered', () => {
// Asymmetric on purpose: an unretractable subscribe is the hazard, a withdrawal
// never is. A stale relay tracker on a preserved daemon must still be able to
// clear a subscription rather than be silenced into stranding it.
const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 28 })
try {
const captured = captureForwardedFacts(legacy)
legacy['removeEventListener'] = null
legacy['setupEventRouting']()
captured.emit({ kind: '2031-unsubscribe' })
expect(captured.kinds()).toEqual(['2031-unsubscribe'])
} finally {
legacy.dispose()
onEventSpy.mockRestore()
}
})
it('forwards 2031 facts from a v29 daemon that can retract them', () => {
const current = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 29 })
try {
const captured = captureForwardedFacts(current)
current['removeEventListener'] = null
current['setupEventRouting']()
captured.emit({ kind: '2031-subscribe' })
captured.emit({ kind: '2031-unsubscribe' })
expect(captured.kinds()).toEqual(['2031-subscribe', '2031-unsubscribe'])
} finally {
current.dispose()
onEventSpy.mockRestore()
}
})
})
describe('background stream thinning compatibility', () => {
it('reports authoritative snapshot support only for protocol v20 and newer', () => {
const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 19 })
@@ -834,6 +1005,111 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
}
})
it.each([
{ protocolVersion: 28, clearsHint: true },
{ protocolVersion: 29, clearsHint: false }
])(
'uses protocol v$protocolVersion background authority before spawn',
async ({ protocolVersion, clearsHint }) => {
const ensureConnectedSpy = vi
.spyOn(DaemonClient.prototype, 'ensureConnected')
.mockResolvedValue()
const requestSpy = vi.spyOn(DaemonClient.prototype, 'request').mockResolvedValue({
isNew: true,
pid: null,
shellState: 'unsupported',
snapshot: null
} as never)
const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify')
const target = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion })
const sessionId = `spawn-v${protocolVersion}-session`
try {
await target.spawn({ sessionId, cols: 80, rows: 24 })
if (clearsHint) {
expect(notifySpy).toHaveBeenCalledWith('setSessionBackground', {
sessionId,
background: false
})
expect(notifySpy.mock.invocationCallOrder[0]).toBeLessThan(
requestSpy.mock.invocationCallOrder[0]
)
} else {
expect(notifySpy).not.toHaveBeenCalledWith(
'setSessionBackground',
expect.objectContaining({ sessionId })
)
}
} finally {
target.dispose()
notifySpy.mockRestore()
requestSpy.mockRestore()
ensureConnectedSpy.mockRestore()
}
}
)
it('clears a preserved v28 background hint before attaching, so scan authority comes home', async () => {
// The gate on setPtyBackgrounded only binds THIS process. Daemons outlive the desktop:
// a v28 that a previous desktop backgrounded is still scanning when a new desktop
// attaches, and this process never called setPtyBackgrounded for it — so without the
// pre-attach clear the daemon keeps authority it can never retract (#9993).
const ensureConnectedSpy = vi
.spyOn(DaemonClient.prototype, 'ensureConnected')
.mockResolvedValue()
const requestSpy = vi.spyOn(DaemonClient.prototype, 'request').mockResolvedValue({
isNew: false,
pid: 4242,
shellState: 'unsupported',
snapshot: null
} as never)
const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify')
const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 28 })
try {
await legacy.attach('preserved-v28-session')
expect(notifySpy).toHaveBeenCalledWith('setSessionBackground', {
sessionId: 'preserved-v28-session',
background: false
})
expect(notifySpy.mock.invocationCallOrder[0]).toBeLessThan(
requestSpy.mock.invocationCallOrder[0]
)
} finally {
legacy.dispose()
notifySpy.mockRestore()
requestSpy.mockRestore()
ensureConnectedSpy.mockRestore()
}
})
it('leaves a v29 background hint alone on attach, because it can retract on its own', async () => {
const ensureConnectedSpy = vi
.spyOn(DaemonClient.prototype, 'ensureConnected')
.mockResolvedValue()
const requestSpy = vi.spyOn(DaemonClient.prototype, 'request').mockResolvedValue({
isNew: false,
pid: 4242,
shellState: 'unsupported',
snapshot: null
} as never)
const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify')
const current = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 29 })
try {
await current.attach('preserved-v29-session')
expect(notifySpy).not.toHaveBeenCalledWith(
'setSessionBackground',
expect.objectContaining({ sessionId: 'preserved-v29-session' })
)
} finally {
current.dispose()
notifySpy.mockRestore()
requestSpy.mockRestore()
ensureConnectedSpy.mockRestore()
}
})
it('still returns the v19 attach snapshot for a desktop renderer replay', async () => {
const ensureConnectedSpy = vi
.spyOn(DaemonClient.prototype, 'ensureConnected')
+37 -4
View File
@@ -16,6 +16,7 @@ import {
AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION,
GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION,
PROTOCOL_VERSION,
supportsMode2031UnsubscribeFact,
supportsPtyStartupIngress,
type CreateOrAttachResult,
type DaemonEvent,
@@ -200,6 +201,16 @@ export class DaemonPtyAdapter implements IPtyProvider {
return this.supportsAuthoritativeBufferSnapshots
}
// Why one predicate (#9993): the attach-time clear and setPtyBackgrounded must agree on
// which daemons may hold a background hint. Daemons outlive the desktop that set it, so
// if these two drift a preserved daemon keeps a hint this process would never grant.
private get canDelegateBackgroundToDaemon(): boolean {
return (
this.supportsAuthoritativeBufferSnapshots &&
supportsMode2031UnsubscribeFact(this.protocolVersion)
)
}
constructor(opts: DaemonPtyAdapterOptions) {
this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION
this.socketPath = opts.socketPath
@@ -360,8 +371,10 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
await this.ensureConnected()
// Why before createOrAttach: a preserved v19 daemon may still think this session is backgrounded; clear it before attached bytes get thinned without a recoverable seq.
if (!this.supportsAuthoritativeBufferSnapshots) {
// Why before createOrAttach: a preserved daemon may still think this session is backgrounded — from
// a v19 that thins without a recoverable seq, or (#9993) from a pre-v29 that a previous desktop
// handed 2031 scan authority to and can never retract it. Clear it before any bytes are attached.
if (!this.canDelegateBackgroundToDaemon) {
this.setPtyBackgrounded(sessionId, false)
}
@@ -717,7 +730,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
async attach(id: string): Promise<void> {
await this.ensureConnected()
if (!this.supportsAuthoritativeBufferSnapshots) {
if (!this.canDelegateBackgroundToDaemon) {
this.setPtyBackgrounded(id, false)
}
@@ -785,7 +798,12 @@ export class DaemonPtyAdapter implements IPtyProvider {
return
}
// Why: preserved v19 daemons can thin but can't return the absolute snapshot sequence to recover a gap; clear their stale hint too.
const safeBackground = this.supportsAuthoritativeBufferSnapshots && background
// Why also gate on 2031 (#9993): backgrounding is what hands transient-fact scan
// authority to the daemon. A pre-v29 daemon can announce a 2031 subscribe but never
// retract it, so a TUI exiting while hidden would strand the subscription and the
// next theme flip would inject CSI 997 into its replacement shell. Declining to
// background keeps main's scanner — which emits both facts — authoritative.
const safeBackground = this.canDelegateBackgroundToDaemon && background
if (safeBackground) {
this.backgroundedSessionIds.add(id)
} else {
@@ -1913,6 +1931,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
background: event.payload.background,
...(event.payload.scanSeedAnsi !== undefined
? { scanSeedAnsi: event.payload.scanSeedAnsi }
: {}),
...(event.payload.mode2031PendingSubscribe
? { mode2031PendingSubscribe: true as const }
: {})
})
} else if (event.event === 'dataGap') {
@@ -1925,6 +1946,18 @@ export class DaemonPtyAdapter implements IPtyProvider {
: { sequenceChars: event.payload.sequenceChars })
})
} else if (event.event === 'transientFact') {
// Why (#9993): belt-and-braces behind the setPtyBackgrounded gate. A pre-v29
// daemon is never asked to background, so it should emit no transient facts at
// all — but one preserved across a reconnect could still have a stale relay
// tracker. An unretractable subscribe is the harmful direction, so drop it.
// An unsubscribe is always forwarded: retiring a subscription main registered
// can only ever help, never strand one.
if (
event.payload.kind === '2031-subscribe' &&
!supportsMode2031UnsubscribeFact(this.protocolVersion)
) {
return
}
this.emitBackgroundStreamEvent({
id: event.sessionId,
kind: 'transientFact',
+8 -2
View File
@@ -868,14 +868,20 @@ export class DaemonServer {
return {}
}
// Reveal intentionally keeps the queued tail: main needs those bytes, and the normal flush/drain delivers them in order ahead of the marker.
const scanSeedAnsi = background ? '' : this.host.getPartialEscapeTailAnsi(sessionId)
const mode2031State = this.transientFactRelay.getMode2031ReplyScanState(sessionId)
const scanSeedAnsi = background
? ''
: mode2031State.pendingSubscribe
? mode2031State.tail
: this.host.getPartialEscapeTailAnsi(sessionId)
this.streamDataBatcher.enqueueControlEvent(streamClientId, sessionId, {
type: 'event',
event: 'sessionBackgroundMarker',
sessionId,
payload: {
background,
...(scanSeedAnsi.length > 0 ? { scanSeedAnsi } : {})
...(scanSeedAnsi.length > 0 ? { scanSeedAnsi } : {}),
...(mode2031State.pendingSubscribe ? { mode2031PendingSubscribe: true as const } : {})
}
})
return {}
@@ -175,6 +175,37 @@ describe('daemon stream droppability lifecycle', () => {
expect(lifecycle).toEqual(['refresh:false', 'marker:false'])
})
it('returns a provisional 2031 subscribe with the foreground handoff marker', async () => {
const harness = createServerHarness()
server = harness.server
const { daemon } = harness
addClient(daemon)
daemon.streamClientIdBySessionId.set('session-toggle', 'client-1')
daemon.transientFactRelay.setSessionBackground('session-toggle', true)
daemon.transientFactRelay.onSessionData('session-toggle', '\x1b[?2031h\x1b[?')
vi.spyOn(daemon.host, 'getPartialEscapeTailAnsi').mockReturnValue('\x1b[?')
const enqueue = vi.spyOn(daemon.streamDataBatcher, 'enqueueControlEvent')
await daemon.routeRequest('client-1', {
id: 'foreground',
type: 'setSessionBackground',
payload: { sessionId: 'session-toggle', background: false }
})
expect(enqueue).toHaveBeenCalledWith(
'client-1',
'session-toggle',
expect.objectContaining({
event: 'sessionBackgroundMarker',
payload: {
background: false,
scanSeedAnsi: '\x1b[?',
mode2031PendingSubscribe: true
}
})
)
})
it('routes an attached session before refresh and emits its background marker after', async () => {
const harness = createServerHarness()
server = harness.server
+8 -2
View File
@@ -41,12 +41,17 @@ export type TerminalErrorEvent = {
* exactly this position so no fact double-fires or goes missing.
* scanSeedAnsi (un-background only) carries the emulator's dangling
* incomplete escape so main can prime its fresh scanner carry — a sequence
* split across the handoff must not mint a phantom bell or lose its fact. */
* split across the handoff must not mint a phantom bell or lose its fact.
* mode2031PendingSubscribe preserves a subscribe deferred behind that tail. */
export type SessionBackgroundMarkerEvent = {
type: 'event'
event: 'sessionBackgroundMarker'
sessionId: string
payload: { background: boolean; scanSeedAnsi?: string }
payload: {
background: boolean
scanSeedAnsi?: string
mode2031PendingSubscribe?: true
}
}
/** A backgrounded session's oldest undelivered output was dropped at the
@@ -69,6 +74,7 @@ export type DaemonTransientFact =
| { kind: 'command-finished'; exitCode: number | null }
| { kind: 'pr-link'; link: TerminalGitHubPRLink }
| { kind: '2031-subscribe' }
| { kind: '2031-unsubscribe' }
export type TransientFactEvent = {
type: 'event'
@@ -278,14 +278,16 @@ describe('incremental terminal history restore', () => {
const firstReplay = reader.detectColdRestore(SESSION_ID)
const secondReplay = reader.detectColdRestore(secondSessionId)
try {
// Drain by yield because awaiting the session that loses the replay-slot race deadlocks.
await vi.waitFor(() => expect(pendingYields).toHaveLength(1))
pendingYields.shift()!()
expect((await firstReplay)?.scrollbackAnsi).toContain('😀second')
await vi.waitFor(() => expect(pendingYields).toHaveLength(1))
pendingYields.shift()!()
expect((await secondReplay)?.scrollbackAnsi).toContain('😀second')
for (const restore of await Promise.all([firstReplay, secondReplay])) {
expect(restore?.scrollbackAnsi).toContain('😀second')
}
expect(pendingYields).toHaveLength(0)
} finally {
for (const resume of pendingYields.splice(0)) {
+2
View File
@@ -32,6 +32,8 @@ export {
PREVIOUS_DAEMON_PROTOCOL_VERSIONS,
PROTOCOL_VERSION,
PTY_STARTUP_INGRESS_PROTOCOL_VERSION,
MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION,
supportsMode2031UnsubscribeFact,
supportsPtyStartupIngress
} from './daemon-protocol-version'
@@ -0,0 +1,55 @@
import { execFileSync } from 'node:child_process'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { getBranchCompare } from './status'
const tempRoots: string[] = []
function git(repo: string, args: string[]): string {
return execFileSync('git', args, {
cwd: repo,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
}).trim()
}
afterEach(async () => {
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('getBranchCompare real refs', () => {
it('preserves the raw oid of a remote-tracking ref that stores an annotated tag', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'orca-branch-compare-ref-'))
tempRoots.push(root)
const source = path.join(root, 'source')
const client = path.join(root, 'client')
execFileSync('git', ['init', '-q', source])
git(source, ['config', 'user.email', 'test@example.com'])
git(source, ['config', 'user.name', 'Test User'])
git(source, ['config', 'commit.gpgSign', 'false'])
git(source, ['config', 'tag.gpgSign', 'false'])
git(source, ['commit', '--allow-empty', '-m', 'initial'])
git(source, ['tag', '-a', 'annotated', '-m', 'annotated base'])
execFileSync('git', ['clone', '-q', source, client])
git(client, ['fetch', source, 'refs/tags/annotated:refs/remotes/origin/tagbase'])
expect(git(client, ['branch', '-r', '--format=%(refname:short)']).split(/\r?\n/)).toContain(
'origin/tagbase'
)
const rawOid = git(client, ['rev-parse', '--verify', 'refs/remotes/origin/tagbase'])
const peeledOid = git(client, [
'rev-parse',
'--verify',
'--quiet',
'refs/remotes/origin/tagbase^{commit}'
])
expect(rawOid).not.toBe(peeledOid)
const result = await getBranchCompare(client, 'origin/tagbase')
expect(result.summary).toMatchObject({ baseOid: rawOid, status: 'ready' })
})
})
+176 -51
View File
@@ -1929,21 +1929,72 @@ describe('getBranchCompare', () => {
readFileMock.mockReset()
})
// Why dispatch on args instead of mockResolvedValueOnce chains: getBranchCompare now
// issues its head-of-chain reads concurrently, so a positional mock would encode call
// order rather than behaviour and break on any safe reordering.
type BranchCompareGitResponses = {
branch?: string | Error
probe?: Record<string, string | Error>
headOid?: string | Error
baseOid?: string | Error
mergeBase?: string | Error
nameStatus?: string | Error
numstat?: string | Error
revList?: string | Error
}
function mockBranchCompareGit(responses: BranchCompareGitResponses): void {
const reply = (
value: string | Error | undefined,
label: string
): Promise<{ stdout: string }> => {
if (value === undefined) {
throw new Error(`unexpected git call: ${label}`)
}
return value instanceof Error ? Promise.reject(value) : Promise.resolve({ stdout: value })
}
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
if (args[0] === 'branch') {
return reply(responses.branch, 'branch --show-current')
}
if (args[0] === 'rev-parse' && args.includes('--quiet')) {
const probed = args.find((arg) => arg.endsWith('^{commit}')) ?? ''
return reply(responses.probe?.[probed], `probe ${probed}`)
}
if (args[0] === 'rev-parse' && args.includes('HEAD')) {
return reply(responses.headOid, 'rev-parse HEAD')
}
if (args[0] === 'rev-parse') {
return reply(responses.baseOid, `rev-parse ${args.at(-1)}`)
}
if (args[0] === 'merge-base') {
return reply(responses.mergeBase, 'merge-base')
}
if (args.includes('--name-status')) {
return reply(responses.nameStatus, 'diff --name-status')
}
if (args.includes('--numstat')) {
return reply(responses.numstat, 'diff --numstat')
}
if (args[0] === 'rev-list') {
return reply(responses.revList, 'rev-list')
}
throw new Error(`unexpected git args: ${args.join(' ')}`)
})
}
it('returns a pinned branch compare snapshot and parsed branch entries', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'main\n' })
.mockResolvedValueOnce({ stdout: 'remote-base-oid\n' })
.mockResolvedValueOnce({ stdout: 'head-oid\n' })
.mockResolvedValueOnce({ stdout: 'base-oid\n' })
.mockResolvedValueOnce({ stdout: 'merge-base-oid\n' })
.mockResolvedValueOnce({
stdout: 'M\tfile-a.ts\nR100\told-name.ts\tnew-name.ts\nC100\told-copy.ts\tnew-copy.ts\n'
})
.mockResolvedValueOnce({
stdout:
'10\t2\tfile-a.ts\n1\t1\told-name.ts => new-name.ts\n3\t0\told-copy.ts => new-copy.ts\n'
})
.mockResolvedValueOnce({ stdout: '7\n' })
mockBranchCompareGit({
branch: 'main\n',
probe: { 'refs/remotes/origin/main^{commit}': 'base-oid\n' },
headOid: 'head-oid\n',
baseOid: 'base-oid\n',
mergeBase: 'merge-base-oid\n',
nameStatus: 'M\tfile-a.ts\nR100\told-name.ts\tnew-name.ts\nC100\told-copy.ts\tnew-copy.ts\n',
numstat:
'10\t2\tfile-a.ts\n1\t1\told-name.ts => new-name.ts\n3\t0\told-copy.ts => new-copy.ts\n',
revList: '7\n'
})
const result = await getBranchCompare('/repo', 'origin/main')
@@ -1965,12 +2016,15 @@ describe('getBranchCompare', () => {
})
it('returns invalid-base when the compare ref does not resolve', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'main\n' })
.mockRejectedValueOnce(new Error('missing remote base'))
.mockRejectedValueOnce(new Error('missing local base'))
.mockResolvedValueOnce({ stdout: 'head-oid\n' })
.mockRejectedValueOnce(new Error('missing base'))
mockBranchCompareGit({
branch: 'main\n',
probe: {
'refs/remotes/origin/missing^{commit}': new Error('missing remote base'),
'refs/heads/origin/missing^{commit}': new Error('missing local base')
},
headOid: 'head-oid\n',
baseOid: new Error('missing base')
})
const result = await getBranchCompare('/repo', 'origin/missing')
@@ -1980,11 +2034,13 @@ describe('getBranchCompare', () => {
})
it('returns unborn-head when HEAD cannot be resolved', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'main\n' })
.mockResolvedValueOnce({ stdout: 'remote-base-oid\n' })
.mockRejectedValueOnce(new Error('unborn'))
.mockRejectedValueOnce(new Error('missing base'))
mockBranchCompareGit({
branch: 'main\n',
// Why the probe fails here: a proven base ref would resolve, giving 'ready'.
probe: { 'refs/remotes/origin/main^{commit}': new Error('missing base') },
headOid: new Error('unborn'),
baseOid: new Error('missing base')
})
const result = await getBranchCompare('/repo', 'origin/main')
@@ -1994,11 +2050,12 @@ describe('getBranchCompare', () => {
})
it('treats an unborn branch with a resolvable base as having no committed branch changes', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'feature\n' })
.mockResolvedValueOnce({ stdout: 'remote-base-oid\n' })
.mockRejectedValueOnce(new Error('unborn'))
.mockResolvedValueOnce({ stdout: 'base-oid\n' })
mockBranchCompareGit({
branch: 'feature\n',
probe: { 'refs/remotes/origin/main^{commit}': 'base-oid\n' },
headOid: new Error('unborn'),
baseOid: 'base-oid\n'
})
const result = await getBranchCompare('/repo', 'origin/main')
@@ -2016,12 +2073,13 @@ describe('getBranchCompare', () => {
})
it('returns no-merge-base when histories do not intersect', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'main\n' })
.mockResolvedValueOnce({ stdout: 'remote-base-oid\n' })
.mockResolvedValueOnce({ stdout: 'head-oid\n' })
.mockResolvedValueOnce({ stdout: 'base-oid\n' })
.mockRejectedValueOnce(new Error('no merge base'))
mockBranchCompareGit({
branch: 'main\n',
probe: { 'refs/remotes/origin/main^{commit}': 'base-oid\n' },
headOid: 'head-oid\n',
baseOid: 'base-oid\n',
mergeBase: new Error('no merge base')
})
const result = await getBranchCompare('/repo', 'origin/main')
@@ -2031,20 +2089,20 @@ describe('getBranchCompare', () => {
})
it('passes core.quotePath=false to diff --name-status and parses UTF-8 paths', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'main\n' })
.mockResolvedValueOnce({ stdout: 'remote-base-oid\n' })
.mockResolvedValueOnce({ stdout: 'head-oid\n' })
.mockResolvedValueOnce({ stdout: 'base-oid\n' })
.mockResolvedValueOnce({ stdout: 'merge-base-oid\n' })
.mockResolvedValueOnce({ stdout: 'M\tdocs/日本語/sample.md\n' })
.mockResolvedValueOnce({ stdout: '2\t1\tdocs/日本語/sample.md\n' })
.mockResolvedValueOnce({ stdout: '1\n' })
mockBranchCompareGit({
branch: 'main\n',
probe: { 'refs/remotes/origin/main^{commit}': 'base-oid\n' },
headOid: 'head-oid\n',
baseOid: 'base-oid\n',
mergeBase: 'merge-base-oid\n',
nameStatus: 'M\tdocs/日本語/sample.md\n',
numstat: '2\t1\tdocs/日本語/sample.md\n',
revList: '1\n'
})
const result = await getBranchCompare('/repo', 'origin/main')
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(
6,
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
[
'-c',
'core.quotePath=false',
@@ -2062,7 +2120,70 @@ describe('getBranchCompare', () => {
])
})
it('compares short remote labels through fully qualified remote-tracking refs', async () => {
// Why: the base oid now comes from the probe, so the probe must never report a ref it
// did not actually resolve -- an empty rev-parse --quiet stdout means "not found".
it('treats an empty probe result as an unresolved base ref', async () => {
mockBranchCompareGit({
branch: 'main\n',
probe: {
'refs/remotes/origin/main^{commit}': '\n',
'refs/heads/origin/main^{commit}': '\n'
},
headOid: 'head-oid\n',
baseOid: new Error('missing base')
})
const result = await getBranchCompare('/repo', 'origin/main')
expect(result.summary.status).toBe('invalid-base')
})
// Why: the probe tries refs/remotes first, then refs/heads. Reusing "the last probed
// oid" rather than the oid for the ref that won would return the wrong commit.
it('reuses only the oid of the ref the probe actually resolved', async () => {
mockBranchCompareGit({
branch: 'feature\n',
probe: {
'refs/remotes/origin/main^{commit}': new Error('no remote-tracking ref'),
'refs/heads/origin/main^{commit}': 'local-branch-oid\n'
},
headOid: 'head-oid\n',
mergeBase: 'merge-base-oid\n',
nameStatus: '',
numstat: '',
revList: '0\n'
})
const result = await getBranchCompare('/repo', 'origin/main')
expect(result.summary).toMatchObject({
baseOid: 'local-branch-oid',
status: 'ready'
})
})
// Why: an already-qualified base ref skips the probe entirely, so its oid must still
// come from rev-parse rather than from a stale or absent probe entry.
it('resolves an already-qualified base ref without a probe', async () => {
mockBranchCompareGit({
branch: 'main\n',
headOid: 'head-oid\n',
baseOid: 'qualified-base-oid\n',
mergeBase: 'merge-base-oid\n',
nameStatus: '',
numstat: '',
revList: '0\n'
})
const result = await getBranchCompare('/repo', 'refs/remotes/origin/main')
expect(result.summary).toMatchObject({
baseOid: 'qualified-base-oid',
status: 'ready'
})
})
it('resolves remote-tracking refs separately after probing their commit target', async () => {
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
if (args[0] === 'branch') {
return Promise.resolve({ stdout: 'feature\n' })
@@ -2072,13 +2193,13 @@ describe('getBranchCompare', () => {
args.includes('--quiet') &&
args.includes('refs/remotes/origin/main^{commit}')
) {
return Promise.resolve({ stdout: 'remote-base-oid\n' })
return Promise.resolve({ stdout: 'peeled-base-oid\n' })
}
if (args[0] === 'rev-parse' && args.includes('HEAD')) {
return Promise.resolve({ stdout: 'head-oid\n' })
}
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main')) {
return Promise.resolve({ stdout: 'base-oid\n' })
return Promise.resolve({ stdout: 'raw-base-oid\n' })
}
if (args[0] === 'merge-base') {
return Promise.resolve({ stdout: 'merge-base-oid\n' })
@@ -2099,9 +2220,13 @@ describe('getBranchCompare', () => {
expect(result.summary).toMatchObject({
baseRef: 'origin/main',
baseOid: 'base-oid',
baseOid: 'raw-base-oid',
status: 'ready'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'],
{ cwd: '/repo' }
)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['rev-parse', '--verify', '--end-of-options', 'refs/remotes/origin/main'],
{ cwd: '/repo' }
+32 -16
View File
@@ -44,8 +44,9 @@ import {
removeSafeUntrackedDiscardTarget,
removeSafeUntrackedDiscardTargets
} from '../../shared/git-discard-path-safety'
import { readBranchCompareHead } from '../../shared/git-branch-compare-head'
import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref'
import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe'
import { resolveWorktreeBaseCommitOid } from './worktree-base-ref-probe'
import { getLargeDiffRenderLimit } from '../../shared/large-diff-render-limit'
import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe'
import type { GitRuntimeOptions } from './git-runtime-options'
@@ -1304,29 +1305,44 @@ export async function getBranchCompare(
status: 'loading'
}
const compareRef = await resolveCompareRef(worktreePath, options)
// The base-ref probe peels to a commit. Only branch refs are guaranteed to store
// commits; remote-tracking refs may store annotated tags whose raw oid must be preserved.
const reusableProbedOidByRef = new Map<string, string>()
const { compareRef, headOidResult, baseOidResult } = await readBranchCompareHead({
readCompareRef: () => resolveCompareRef(worktreePath, options),
resolveBaseRef: () =>
// Why: short refs like "origin/main" can collide with a local branch; use the proven remote-tracking ref.
resolveWorktreeAddBaseRef(baseRef, async (qualifiedRef) => {
const oid = await resolveWorktreeBaseCommitOid(worktreePath, qualifiedRef, options)
if (oid !== null && qualifiedRef.startsWith('refs/heads/')) {
reusableProbedOidByRef.set(qualifiedRef, oid)
}
return oid !== null
}),
readHeadOid: () => resolveRefOid(worktreePath, 'HEAD', options),
readBaseOid: (ref) => {
const reusableOid = reusableProbedOidByRef.get(ref)
return reusableOid === undefined
? resolveRefOid(worktreePath, ref, options)
: Promise.resolve(reusableOid)
}
})
summary.compareRef = compareRef
// Why: short refs like "origin/main" can collide with a local branch; use the proven remote-tracking ref.
const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, (qualifiedRef) =>
hasWorktreeBaseCommitRef(worktreePath, qualifiedRef, options)
)
let headOid = ''
let baseOid = ''
try {
headOid = await resolveRefOid(worktreePath, 'HEAD', options)
if (headOidResult.ok) {
headOid = headOidResult.oid
summary.headOid = headOid
} catch {
try {
baseOid = await resolveRefOid(worktreePath, resolvedBaseRef, options)
} else {
if (baseOidResult.ok) {
baseOid = baseOidResult.oid
summary.baseOid = baseOid
// Why: an unborn branch (new remote worktree) has no changes yet; a compare error would look broken.
summary.changedFiles = 0
summary.commitsAhead = 0
summary.status = 'ready'
return { summary, entries: [] }
} catch {
// Preserve the unborn-head message when even the base is unresolvable.
}
summary.status = 'unborn-head'
summary.errorMessage =
@@ -1334,10 +1350,10 @@ export async function getBranchCompare(
return { summary, entries: [] }
}
try {
baseOid = await resolveRefOid(worktreePath, resolvedBaseRef, options)
if (baseOidResult.ok) {
baseOid = baseOidResult.oid
summary.baseOid = baseOid
} catch {
} else {
summary.status = 'invalid-base'
summary.errorMessage = `Base ref ${baseRef} could not be resolved in this repository.`
return { summary, entries: [] }
+19 -4
View File
@@ -4,11 +4,17 @@ type GitExecOptions = {
wslDistro?: string
}
export async function hasWorktreeBaseCommitRef(
/**
* Returns the probed commit oid, or null when the ref does not resolve.
*
* Why expose the oid: the probe already prints it, and callers that then need the
* same ref's oid were re-spawning `rev-parse` for a value this call threw away.
*/
export async function resolveWorktreeBaseCommitOid(
repoPath: string,
qualifiedRef: string,
options: GitExecOptions = {}
): Promise<boolean> {
): Promise<string | null> {
try {
const { stdout } = await gitExecFileAsync(
['rev-parse', '--verify', '--quiet', `${qualifiedRef}^{commit}`],
@@ -17,8 +23,17 @@ export async function hasWorktreeBaseCommitRef(
...options
}
)
return stdout.trim().length > 0
const oid = stdout.trim()
return oid.length > 0 ? oid : null
} catch {
return false
return null
}
}
export async function hasWorktreeBaseCommitRef(
repoPath: string,
qualifiedRef: string,
options: GitExecOptions = {}
): Promise<boolean> {
return (await resolveWorktreeBaseCommitOid(repoPath, qualifiedRef, options)) !== null
}
@@ -50,6 +50,56 @@ describe('dashboard payload validation', () => {
).toBe(false)
})
it('accepts repo icons a pop-out can safely render, and rejects the rest', () => {
expect(
isDashboardSnapshot({
...SNAPSHOT,
repoIconsByRepoId: {
'repo-1': { type: 'lucide', name: 'Rocket' },
'repo-2': null,
'repo-3': {
type: 'image',
src: 'https://github.com/anthropics.png?size=64',
source: 'github'
}
}
})
).toBe(true)
// Absent entirely: a pop-out on older code still gets its snapshot.
expect(isDashboardSnapshot({ ...SNAPSHOT, repoIconsByRepoId: undefined })).toBe(true)
expect(
isDashboardSnapshot({
...SNAPSHOT,
repoIconsByRepoId: {
'repo-1': { type: 'image', src: 'javascript:alert(1)', source: 'file' }
}
})
).toBe(false)
expect(
isDashboardSnapshot({
...SNAPSHOT,
repoIconsByRepoId: { 'repo-1': { type: 'nonsense' } }
})
).toBe(false)
expect(isDashboardSnapshot({ ...SNAPSHOT, repoIconsByRepoId: [] })).toBe(false)
})
it('bounds the conversation name', () => {
expect(
isDashboardSnapshot({
...SNAPSHOT,
cards: [{ ...SNAPSHOT.cards[0], conversationName: 'Sparse-checkout parser' }]
})
).toBe(true)
expect(
isDashboardSnapshot({
...SNAPSHOT,
cards: [{ ...SNAPSHOT.cards[0], conversationName: 'x'.repeat(1_025) }]
})
).toBe(false)
})
it('requires complete bounded reveal routing', () => {
expect(
isDashboardRevealAgentArgs({
+26 -2
View File
@@ -1,4 +1,5 @@
import type { DashboardRevealAgentArgs, DashboardSnapshot } from '../../shared/dashboard-snapshot'
import { sanitizeRepoIcon } from '../../shared/repo-icon'
import {
AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH,
AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH,
@@ -7,6 +8,7 @@ import {
} from '../../shared/agent-status-types'
const MAX_DASHBOARD_CARDS = 1_000
const MAX_DASHBOARD_REPO_ICONS = 500
const MAX_ID_LENGTH = 4_096
const MAX_LABEL_LENGTH = 1_024
const DASHBOARD_BUCKETS = new Set(['attention', 'working', 'idle'])
@@ -50,7 +52,28 @@ export function isDashboardSnapshot(value: unknown): value is DashboardSnapshot
isFiniteNumber(snapshot.generatedAt) &&
Array.isArray(snapshot.cards) &&
snapshot.cards.length <= MAX_DASHBOARD_CARDS &&
snapshot.cards.every(isDashboardCard)
snapshot.cards.every(isDashboardCard) &&
isDashboardRepoIcons(snapshot.repoIconsByRepoId)
)
}
/** Repo icons reach the pop-out's `<img src>`, so each one must survive the
* same sanitizer the settings picker writes through. */
function isDashboardRepoIcons(value: unknown): boolean {
if (value === undefined) {
return true
}
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false
}
const entries = Object.entries(value as Record<string, unknown>)
return (
entries.length <= MAX_DASHBOARD_REPO_ICONS &&
entries.every(
([repoId, icon]) =>
isBoundedString(repoId, MAX_ID_LENGTH) &&
(icon === null || sanitizeRepoIcon(icon) !== undefined)
)
)
}
@@ -80,6 +103,7 @@ function isDashboardCard(value: unknown): boolean {
(card.finishedAt === null || isFiniteNumber(card.finishedAt)) &&
isFiniteNumber(card.stateChangedAt) &&
typeof card.unseen === 'boolean' &&
isOptionalBoundedString(card.askSummary, AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH)
isOptionalBoundedString(card.askSummary, AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH) &&
isOptionalBoundedString(card.conversationName, MAX_LABEL_LENGTH)
)
}
@@ -1,3 +1,5 @@
import type { Mode2031ReplyScanState } from '../../shared/terminal-color-scheme-protocol'
export type PendingPtyData = {
data: string
startSeq?: number
@@ -5,6 +7,8 @@ export type PendingPtyData = {
transformed?: true
containsBackgroundOutput?: boolean
droppedOutput?: true
droppedMode2031Data?: string
droppedMode2031ScanState?: Mode2031ReplyScanState
}
export type PtyPendingDataDrainDisposition = 'active' | 'background' | 'blocked'
+7 -5
View File
@@ -11006,17 +11006,19 @@ describe('registerPtyHandlers', () => {
vi.advanceTimersByTime(1)
}
// Flood past the pending cap WITH an embedded DSR probe — the writing program blocks on the reply (bench DSR timeout).
mockProc.emitData(`${'y'.repeat(2 * 1024 * 1024)}\x1b[6n${'y'.repeat(1024 * 1024)}`)
// While latched, a later probe must also be carved out (bounded).
mockProc.emitData(`${'z'.repeat(32 * 1024)}\x1b[0c${'z'.repeat(32 * 1024)}`)
// Flood past the cap with a DSR probe and a mode-2031 withdrawal split at the chunk edge.
mockProc.emitData(
`${'y'.repeat(2 * 1024 * 1024)}\x1b[6n${'y'.repeat(1024 * 1024)}\x1b[?2031h prompt \x1b[?20`
)
// While latched, later queries and the withdrawal continuation must still be carved out.
mockProc.emitData(`31l${'z'.repeat(32 * 1024)}\x1b[0c${'z'.repeat(32 * 1024)}`)
mainWindow.webContents.send.mockClear()
ackData(null, { id: spawn.id, charCount: 512 * 1024 })
vi.advanceTimersByTime(2)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawn.id,
data: '\x1b[6n\x1b[0c',
data: '\x1b[6n\x1b[0c\x1b[?2031l',
droppedOutput: true
})
} finally {
+56 -8
View File
@@ -24,6 +24,11 @@ import type {
PtyRendererDeliveryStateReport
} from '../../shared/pty-renderer-delivery-health'
import { extractHiddenStartupRendererQueryData } from '../../shared/terminal-reply-query-extraction'
import {
INITIAL_MODE_2031_REPLY_SCAN_STATE,
scanMode2031ReplyDecision,
type Mode2031ReplyScanState
} from '../../shared/terminal-color-scheme-protocol'
import {
type PtyMainDeliveryDiagnostics,
type PtyPerPtyDeliveryDiagnostics,
@@ -2462,6 +2467,29 @@ export function registerPtyHandlers(
return extracted.statelessQueryData + extracted.statefulQueryData + extracted.oscColorQueryData
}
function scanDroppedMode2031Data(
data: string,
previous: Mode2031ReplyScanState
): { data: string; state: Mode2031ReplyScanState } {
const result = scanMode2031ReplyDecision(previous, data)
const decisionData =
result.decision === 'subscribed'
? '\x1b[?2031h'
: result.decision === 'unsubscribed'
? '\x1b[?2031l'
: ''
return { data: decisionData, state: result.state }
}
function getDroppedMode2031RendererData(pending: PendingPtyData): string {
const state = pending.droppedMode2031ScanState
if (!state) {
return pending.droppedMode2031Data ?? ''
}
const pendingSubscribe = state.pendingSubscribe ? '\x1b[?2031h' : ''
return (pending.droppedMode2031Data ?? '') + pendingSubscribe + state.tail
}
function dropOversizedPendingPtyData(id: string, pending: PendingPtyData): PendingPtyData {
const capChars = pendingDataCapChars()
if (pending.droppedOutput === true || pending.data.length <= capChars) {
@@ -2482,10 +2510,13 @@ export function registerPtyHandlers(
pendingOverflowMarkedPtys.add(id)
}
pendingDroppedChars += pending.data.length
const mode2031 = scanDroppedMode2031Data(pending.data, INITIAL_MODE_2031_REPLY_SCAN_STATE)
// Why no trimmed content tail: a mid-stream gap would corrupt the pane; the droppedOutput sentinel repaints from the snapshot and realigns by sequence (only query bytes ride along).
return {
data: extractDroppedPtyQueryBytes(pending.data).slice(0, DROPPED_QUERY_SALVAGE_MAX_CHARS),
droppedOutput: true
droppedOutput: true,
droppedMode2031Data: mode2031.data,
droppedMode2031ScanState: mode2031.state
}
}
@@ -2501,11 +2532,21 @@ export function registerPtyHandlers(
): PendingPtyData {
// Why stay dropped at O(1): once over the cap the restore sentinel supersedes interim bytes; queries still get carved out (bounded) so replies survive the whole episode.
if (existing?.droppedOutput === true) {
if (existing.data.length >= DROPPED_QUERY_SALVAGE_MAX_CHARS) {
return existing
const mode2031 = scanDroppedMode2031Data(
data,
existing.droppedMode2031ScanState ?? INITIAL_MODE_2031_REPLY_SCAN_STATE
)
const remainingQueryCapacity = Math.max(
0,
DROPPED_QUERY_SALVAGE_MAX_CHARS - existing.data.length
)
const salvaged = extractDroppedPtyQueryBytes(data).slice(0, remainingQueryCapacity)
return {
...existing,
data: existing.data + salvaged,
droppedMode2031Data: mode2031.data || existing.droppedMode2031Data,
droppedMode2031ScanState: mode2031.state
}
const salvaged = extractDroppedPtyQueryBytes(data)
return salvaged ? { ...existing, data: existing.data + salvaged } : existing
}
const nextContainsBackgroundOutput =
existing?.containsBackgroundOutput === true || containsBackgroundOutput
@@ -2623,7 +2664,13 @@ export function registerPtyHandlers(
pendingData.remove(selection)
updateProducerFlowControl(id)
// Why droppedOutput sentinel: pending-cap drop means the pane must repaint from the snapshot, not continue a gapped stream (data = carved query bytes only).
if (!sendPtyDataToRenderer(id, { id, data: pending.data, droppedOutput: true })) {
if (
!sendPtyDataToRenderer(id, {
id,
data: pending.data + getDroppedMode2031RendererData(pending),
droppedOutput: true
})
) {
sendFailed = true
break
}
@@ -2849,7 +2896,8 @@ export function registerPtyHandlers(
runtime?.setPtyTransientFactDelegation(
payload.id,
payload.background,
payload.scanSeedAnsi
payload.scanSeedAnsi,
payload.mode2031PendingSubscribe
)
return
}
@@ -2930,7 +2978,7 @@ export function registerPtyHandlers(
pending.droppedOutput === true &&
!overflowMarkedBeforeAppend &&
pendingOverflowMarkedPtys.has(payload.id)
const nextData = pending.data
const nextData = pending.data + getDroppedMode2031RendererData(pending)
const isInteractiveOutput = shouldSendInteractiveOutputNow(
payload.id,
nextData,
+8 -1
View File
@@ -15,8 +15,15 @@ export type PtyTransientFact =
| { kind: 'command-finished'; exitCode: number | null }
| { kind: 'pr-link'; link: TerminalGitHubPRLink }
| { kind: '2031-subscribe' }
| { kind: '2031-unsubscribe' }
export type PtyBackgroundStreamEvent =
| { id: string; kind: 'backgroundMarker'; background: boolean; scanSeedAnsi?: string }
| {
id: string
kind: 'backgroundMarker'
background: boolean
scanSeedAnsi?: string
mode2031PendingSubscribe?: true
}
| { id: string; kind: 'dataGap'; droppedChars: number; sequenceChars?: number }
| { id: string; kind: 'transientFact'; fact: PtyTransientFact }
@@ -102,6 +102,51 @@ describe('MobileNotificationReplayBuffer', () => {
])
})
it('returns the retained buffer when the watermark came from a previous desktop run', () => {
// The #8591 defect: `seq` is per-process and restarts at 0 every launch, but the
// client's watermark is persisted. A client holding seq 57 meets a counter at 2,
// `57 >= 2` cuts everything, and catch-up stays dead until the new process
// dispatches 57 notifications. The epoch is what makes the two distinguishable.
const previousRun = new MobileNotificationReplayBuffer()
for (let i = 0; i < 57; i++) {
dispatch(previousRun, { notificationId: `old:${i}` })
}
const staleWatermark = 57
const staleEpoch = previousRun.epoch
const afterRestart = new MobileNotificationReplayBuffer()
dispatch(afterRestart, { notificationId: 'agent:one' })
dispatch(afterRestart, { notificationId: 'agent:two' })
// Seq-only (the old behaviour) still silently drops everything...
expect(afterRestart.getMissedSince(staleWatermark)).toEqual([])
// ...but a watermark tagged with the old epoch is recognised as void.
expect(
afterRestart.getMissedSince(staleWatermark, staleEpoch).map((e) => e.notificationId)
).toEqual(['agent:one', 'agent:two'])
})
it('keeps the exact seq cut when the epoch matches the live counter', () => {
const buffer = new MobileNotificationReplayBuffer()
const first = dispatch(buffer, { notificationId: 'agent:one' })
dispatch(buffer, { notificationId: 'agent:two' })
// Same counter: the epoch must not widen the cut into a re-push of delivered events.
expect(
buffer.getMissedSince(first.notificationSeq, buffer.epoch).map((e) => e.notificationId)
).toEqual(['agent:two'])
expect(buffer.getMissedSince(2, buffer.epoch)).toEqual([])
})
it('stamps every recorded event with the buffer epoch, and epochs differ per process', () => {
const buffer = new MobileNotificationReplayBuffer()
const event = dispatch(buffer, { notificationId: 'agent:one' })
expect(event.notificationEpoch).toBe(buffer.epoch)
// A restart must produce a different epoch or the watermark stays ambiguous.
expect(new MobileNotificationReplayBuffer().epoch).not.toBe(buffer.epoch)
})
it('evicts oldest entries once the capacity is exceeded', () => {
const buffer = new MobileNotificationReplayBuffer(2)
dispatch(buffer, { notificationId: 'agent:one' })
+26 -2
View File
@@ -1,3 +1,4 @@
import { randomUUID } from 'node:crypto'
import type { MobileNotificationEvent } from './orca-runtime'
// Why: when a mobile client's socket is reaped (background/sleep, or a warm
@@ -22,6 +23,9 @@ import type { MobileNotificationEvent } from './orca-runtime'
// single field name end-to-end is what makes live and replay interchangeable.
export type ReplayableMobileNotification = MobileNotificationEvent & {
notificationSeq: number
// The counter lifetime `notificationSeq` belongs to. Lets a client tell "seq 3
// is older than my watermark" from "seq 3 came from a counter I've never seen".
notificationEpoch: string
}
// Why: bound the buffer. 256 completes a few minutes of real agent activity and
@@ -35,17 +39,28 @@ export class MobileNotificationReplayBuffer {
private readonly capacity: number
private seq = 0
private readonly buffer: ReplayableMobileNotification[] = []
// Why: `seq` restarts at 0 every desktop launch, but the client's watermark is
// persisted and monotonic. After a desktop restart a client holding seq 57 meets
// a counter at 3, `57 >= 3` cuts everything, and catch-up stays silently dead
// until the new process dispatches 57 notifications. The epoch names the counter's
// lifetime so both sides can tell "nothing missed" from "different counter".
private readonly epochId: string = randomUUID()
constructor(capacity: number = DEFAULT_CAPACITY) {
this.capacity = capacity
}
// Identifies this counter's lifetime. Changes on every desktop restart.
get epoch(): string {
return this.epochId
}
// Records a dispatched event and returns the monotonic seq assigned to it.
// Callers surface the seq so clients can watermark their last-seen position
// (both on the live fan-out and on explicit catch-up requests).
record(event: MobileNotificationEvent): number {
const seq = ++this.seq
this.buffer.push({ ...event, notificationSeq: seq })
this.buffer.push({ ...event, notificationSeq: seq, notificationEpoch: this.epochId })
if (this.buffer.length > this.capacity) {
// Why: insertion-order array; oldest entries sit at the front.
this.buffer.splice(0, this.buffer.length - this.capacity)
@@ -56,7 +71,16 @@ export class MobileNotificationReplayBuffer {
// Returns every recorded event with seq strictly greater than lastSeenSeq.
// Because seq is monotonic and global, this is an exact, idempotent cut:
// the same lastSeenSeq always yields the same result.
getMissedSince(lastSeenSeq: number): ReplayableMobileNotification[] {
//
// `epoch` is the counter lifetime the caller's watermark came from. A mismatch
// means the watermark indexes a counter this process no longer has, so it is
// meaningless here and the whole retained buffer is returned instead — every
// entry in it postdates the restart, so none can have reached that client.
// Omitting `epoch` keeps the seq-only cut, for clients that predate the field.
getMissedSince(lastSeenSeq: number, epoch?: string): ReplayableMobileNotification[] {
if (epoch !== undefined && epoch !== this.epochId) {
return [...this.buffer]
}
if (lastSeenSeq >= this.seq) {
return []
}
+11
View File
@@ -8357,6 +8357,17 @@ describe('OrcaRuntimeService', () => {
expect(batches.flatMap((batch) => batch.facts)).toEqual([{ kind: '2031-subscribe' }])
})
it('restores a provisional 2031 subscribe when daemon scan authority returns', () => {
const { runtime, batches } = createSideEffectRuntime()
syncSinglePty(runtime)
runtime.setPtyTransientFactDelegation('pty-1', true)
runtime.setPtyTransientFactDelegation('pty-1', false, '\x1b[?', true)
runtime.onPtyData('pty-1', '25h', 100)
expect(batches.flatMap((batch) => batch.facts)).toEqual([{ kind: '2031-subscribe' }])
})
it('prefers the tracked title over the renderer snapshot lastTitle', async () => {
const { runtime } = createSideEffectRuntime()
const serializeBuffer = vi.fn().mockResolvedValue({
+36 -5
View File
@@ -2387,6 +2387,7 @@ export type MobileNotificationDispatchEvent = {
worktreeId?: string
notificationId?: string
notificationSeq?: number
notificationEpoch?: string
}
export type RuntimeWorktreeLifecycleEvent =
@@ -2397,6 +2398,7 @@ export type MobileNotificationDismissEvent = {
type: 'dismiss'
notificationId: string
notificationSeq?: number
notificationEpoch?: string
}
export type MobileNotificationEvent =
@@ -8075,7 +8077,12 @@ export class OrcaRuntimeService {
* facts itself and the delivered bytes may be gapped — feeding them to
* main's transient scanners would mint phantom or duplicate facts. Title
* processing stays main-side either way. */
setPtyTransientFactDelegation(ptyId: string, delegated: boolean, scanSeedAnsi?: string): void {
setPtyTransientFactDelegation(
ptyId: string,
delegated: boolean,
scanSeedAnsi?: string,
mode2031PendingSubscribe?: true
): void {
const entry = this.getOrCreatePtyTitleTrackerEntry(ptyId)
entry.tracker.setTransientFactScanningSuppressed(delegated)
if (!delegated && scanSeedAnsi) {
@@ -8083,7 +8090,10 @@ export class OrcaRuntimeService {
// incomplete escape at the handoff position — a sequence split across
// the un-background toggle must not mint a phantom bell or lose its
// fact. titleScanData:'' keeps titles out (they were never suppressed).
entry.tracker.handleChunk(scanSeedAnsi, { titleScanData: '' })
entry.tracker.handleChunk(scanSeedAnsi, {
titleScanData: '',
mode2031PendingSubscribe
})
}
}
@@ -8106,6 +8116,9 @@ export class OrcaRuntimeService {
return
case '2031-subscribe':
this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' })
return
case '2031-unsubscribe':
this.recordTerminalSideEffectFact(ptyId, { kind: '2031-unsubscribe' })
}
}
@@ -8353,6 +8366,12 @@ export class OrcaRuntimeService {
// still sent by the renderer (query authority stays with the view).
onMode2031Subscribe: () => {
this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' })
},
// Why: the gated view never sees the withdrawal bytes either, so the
// subscription registry it keeps for theme flips needs this fact to
// stay truthful.
onMode2031Unsubscribe: () => {
this.recordTerminalSideEffectFact(ptyId, { kind: '2031-unsubscribe' })
}
}
: {})
@@ -10203,7 +10222,12 @@ export class OrcaRuntimeService {
// delivered and feed it back to getMissedSince on reconnect (idempotent catch-up, no dupes).
notifyRuntimeListeners(
this.notificationListeners,
(listener) => listener({ ...event, notificationSeq: seq }),
(listener) =>
listener({
...event,
notificationSeq: seq,
notificationEpoch: this.mobileNotificationReplay.epoch
}),
'mobile-notification'
)
}
@@ -10211,8 +10235,15 @@ export class OrcaRuntimeService {
// Returns notifications dispatched after lastSeenSeq. Idempotent: the same
// watermark always yields the same set, so a client cannot be re-pushed an
// already-delivered event (the adversarial-review gate for #8129).
getMissedNotificationsSince(lastSeenSeq: number): ReplayableMobileNotification[] {
return this.mobileNotificationReplay.getMissedSince(lastSeenSeq)
getMissedNotificationsSince(lastSeenSeq: number, epoch?: string): ReplayableMobileNotification[] {
return this.mobileNotificationReplay.getMissedSince(lastSeenSeq, epoch)
}
// Why (#8591): the seq counter is per-process and restarts at 0 on every desktop
// launch, but the client's watermark is persisted. Clients need the epoch to tell
// a stale watermark from a valid one — see MobileNotificationReplayBuffer.
getMobileNotificationEpoch(): string {
return this.mobileNotificationReplay.epoch
}
dismissMobileNotification(notificationId: string): void {
+11 -4
View File
@@ -20,8 +20,13 @@ const NotificationUnsubscribeParams = z.object({
// exact and idempotent — re-requesting with the same watermark can never
// return an already-delivered event, so reconnects never duplicate local
// pushes (the adversarial-review gate for #8129).
// `epoch` names the counter lifetime lastSeenSeq came from (#8591). The desktop's
// seq restarts at 0 on every launch while the client's watermark is persisted, so
// without it a post-restart watermark silently cuts away everything. Optional: a
// client that predates the field keeps the seq-only cut.
const NotificationGetMissedSinceParams = z.object({
lastSeenSeq: z.number().int().min(0, 'lastSeenSeq must be a non-negative integer')
lastSeenSeq: z.number().int().min(0, 'lastSeenSeq must be a non-negative integer'),
epoch: z.string().optional()
})
// Why: notifications.subscribe streams desktop notification events to mobile
@@ -52,7 +57,9 @@ export const NOTIFICATION_METHODS: readonly RpcAnyMethod[] = [
connectionId
)
emit({ type: 'ready', subscriptionId })
// Why: the epoch rides the ready frame so a reconnecting client learns the
// counter lifetime BEFORE it sends its watermark to getMissedSince (#8591).
emit({ type: 'ready', subscriptionId, epoch: runtime.getMobileNotificationEpoch() })
})
}
}),
@@ -71,8 +78,8 @@ export const NOTIFICATION_METHODS: readonly RpcAnyMethod[] = [
// the monotonic seq, so this is the single source of truth for what the
// client missed while its socket was reaped.
handler: async (params, { runtime }) => {
const missed = runtime.getMissedNotificationsSince(params.lastSeenSeq)
return { notifications: missed }
const missed = runtime.getMissedNotificationsSince(params.lastSeenSeq, params.epoch)
return { notifications: missed, epoch: runtime.getMobileNotificationEpoch() }
}
})
]
+11 -107
View File
@@ -17,6 +17,11 @@ import {
encodeTerminalStreamText,
type TerminalStreamFrame
} from '../../../../shared/terminal-stream-protocol'
import {
iterateTerminalOutputFrameChunks,
type TerminalOutputFrameChunk,
type TerminalOutputMeta
} from '../terminal-output-frame-chunks'
import { TERMINAL_PANE_SPLIT_SOURCES } from '../../../../shared/feature-education-telemetry'
import type { TerminalOscLinkRange } from '../../../../shared/terminal-osc-link-ranges'
import {
@@ -24,7 +29,11 @@ import {
TERMINAL_INPUT_TOO_LARGE_ERROR,
isTerminalInputTooLargeWithYield
} from '../../../../shared/terminal-input'
import { measureClipboardTextByteLength } from '../../../../shared/clipboard-text'
import {
measureTerminalStreamByteLength,
terminalStreamByteLength,
terminalStreamByteLengthExceeds
} from '../terminal-stream-byte-length'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply'
import {
@@ -49,8 +58,7 @@ import {
TERMINAL_MULTIPLEX_ACK_TOTAL_MAX_WINDOW_BYTES,
TERMINAL_MULTIPLEX_MAX_STREAMS_PER_CONNECTION,
TERMINAL_MULTIPLEX_PENDING_MAX_BYTES,
TERMINAL_OUTPUT_BATCH_MAX_BYTES,
TERMINAL_STREAM_CHUNK_BYTES
TERMINAL_OUTPUT_BATCH_MAX_BYTES
} from '../../../../shared/terminal-multiplex-flow-control'
import { drainTerminalMultiplexRoundRobin } from '../terminal-multiplex-round-robin'
@@ -139,19 +147,6 @@ type TerminalOutputChunk = {
meta?: TerminalOutputMeta
}
type TerminalOutputMeta = {
seq?: number
rawLength?: number
transformed?: boolean
cwd?: string
}
type TerminalOutputFrameChunk = {
bytes: Uint8Array<ArrayBufferLike>
seq?: number
opcode?: TerminalStreamOpcode
}
function createTerminalOutputBatcher(onFlush: (data: string, meta?: TerminalOutputMeta) => void): {
push: (data: string, meta?: TerminalOutputMeta) => void
flush: () => void
@@ -240,82 +235,6 @@ function createTerminalOutputBatcher(onFlush: (data: string, meta?: TerminalOutp
}
}
function* iterateTerminalOutputFrameChunks(
data: string,
meta?: TerminalOutputMeta
): Generator<TerminalOutputFrameChunk> {
const rawLength = meta?.rawLength ?? data.length
if (meta?.transformed || rawLength !== data.length) {
yield {
opcode: TerminalStreamOpcode.OutputSpan,
bytes: encodeTerminalStreamJson({ data, rawLength, transformed: true }),
seq: meta?.seq
}
return
}
if (!terminalStreamByteLengthExceeds(data, TERMINAL_STREAM_CHUNK_BYTES)) {
yield { bytes: encodeTerminalStreamText(data), seq: meta?.seq }
return
}
const canPreserveChunkSeq = typeof meta?.seq === 'number' && rawLength === data.length
const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta?.seq === 'number'
const startSeq = canPreserveChunkSeq ? meta.seq! - rawLength : undefined
let chunk = ''
let chunkBytes = 0
let chunkStartOffset = 0
let offset = 0
let delayedChunk: { text: string; seq?: number } | null = null
const takeChunk = (): { text: string; seq?: number } | null => {
if (!chunk) {
return null
}
const chunkSeq = canPreserveChunkSeq ? startSeq! + chunkStartOffset + chunk.length : undefined
const current = { text: chunk, seq: chunkSeq }
chunk = ''
chunkBytes = 0
chunkStartOffset = offset
return current
}
for (const part of data) {
const partBytes = terminalStreamByteLength(part)
if (chunkBytes > 0 && chunkBytes + partBytes > TERMINAL_STREAM_CHUNK_BYTES) {
const nextChunk = takeChunk()
if (nextChunk) {
if (shouldDelayFinalSeq) {
if (delayedChunk) {
yield { bytes: encodeTerminalStreamText(delayedChunk.text) }
}
delayedChunk = nextChunk
} else {
yield { bytes: encodeTerminalStreamText(nextChunk.text), seq: nextChunk.seq }
}
}
}
chunk += part
chunkBytes += partBytes
offset += part.length
}
const finalChunk = takeChunk()
if (shouldDelayFinalSeq) {
// Why: only the final frame can safely carry the high-water mark when rawLength can't map back to UTF-16 offsets.
if (finalChunk) {
if (delayedChunk) {
yield { bytes: encodeTerminalStreamText(delayedChunk.text) }
}
delayedChunk = finalChunk
}
if (delayedChunk) {
yield { bytes: encodeTerminalStreamText(delayedChunk.text), seq: meta.seq }
}
return
}
if (finalChunk) {
yield { bytes: encodeTerminalStreamText(finalChunk.text), seq: finalChunk.seq }
}
}
function isTerminalInputLockedForClient(
runtime: OrcaRuntimeService,
ptyId: string,
@@ -541,13 +460,6 @@ function trimPendingOutputToBudget(
return { bytes: pendingOutputBytes, overflowed: omittedChunkCount > 0 }
}
function measureTerminalStreamByteLength(
data: string,
options: { stopAfterBytes?: number } = {}
): { byteLength: number; exceededLimit: boolean } {
return measureClipboardTextByteLength(data, options)
}
function trimPendingOutputCoveredBySnapshot(
pendingOutput: TerminalOutputChunk[],
snapshotSeq: number | undefined
@@ -585,14 +497,6 @@ function trimPendingOutputCoveredBySnapshot(
return { chunks, bytes }
}
function terminalStreamByteLength(data: string): number {
return measureTerminalStreamByteLength(data).byteLength
}
function terminalStreamByteLengthExceeds(data: string, maxBytes: number): boolean {
return measureTerminalStreamByteLength(data, { stopAfterBytes: maxBytes }).exceededLimit
}
function* iterateTerminalStreamTextPayloads(data: string): Generator<Uint8Array<ArrayBufferLike>> {
if (!data) {
return
@@ -0,0 +1,477 @@
import { describe, expect, it } from 'vitest'
import {
TerminalStreamOpcode,
encodeTerminalStreamJson,
encodeTerminalStreamText
} from '../../../shared/terminal-stream-protocol'
import { TERMINAL_STREAM_CHUNK_BYTES } from '../../../shared/terminal-multiplex-flow-control'
import { measureClipboardTextByteLength } from '../../../shared/clipboard-text'
import {
iterateTerminalOutputFrameChunks,
type TerminalOutputFrameChunk,
type TerminalOutputMeta
} from './terminal-output-frame-chunks'
// Byte-for-byte reference: the pre-optimization implementation, copied verbatim.
// It accumulates `chunk += part` over `for (const part of data)` and measures each
// code point through the shared clipboard measurer.
function legacyByteLength(data: string): number {
return measureClipboardTextByteLength(data).byteLength
}
function legacyByteLengthExceeds(data: string, maxBytes: number): boolean {
return measureClipboardTextByteLength(data, { stopAfterBytes: maxBytes }).exceededLimit
}
function* legacyIterateTerminalOutputFrameChunks(
data: string,
meta?: TerminalOutputMeta
): Generator<TerminalOutputFrameChunk> {
const rawLength = meta?.rawLength ?? data.length
if (meta?.transformed || rawLength !== data.length) {
yield {
opcode: TerminalStreamOpcode.OutputSpan,
bytes: encodeTerminalStreamJson({ data, rawLength, transformed: true }),
seq: meta?.seq
}
return
}
if (!legacyByteLengthExceeds(data, TERMINAL_STREAM_CHUNK_BYTES)) {
yield { bytes: encodeTerminalStreamText(data), seq: meta?.seq }
return
}
const canPreserveChunkSeq = typeof meta?.seq === 'number' && rawLength === data.length
const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta?.seq === 'number'
const startSeq = canPreserveChunkSeq ? meta.seq! - rawLength : undefined
let chunk = ''
let chunkBytes = 0
let chunkStartOffset = 0
let offset = 0
let delayedChunk: { text: string; seq?: number } | null = null
const takeChunk = (): { text: string; seq?: number } | null => {
if (!chunk) {
return null
}
const chunkSeq = canPreserveChunkSeq ? startSeq! + chunkStartOffset + chunk.length : undefined
const current = { text: chunk, seq: chunkSeq }
chunk = ''
chunkBytes = 0
chunkStartOffset = offset
return current
}
for (const part of data) {
const partBytes = legacyByteLength(part)
if (chunkBytes > 0 && chunkBytes + partBytes > TERMINAL_STREAM_CHUNK_BYTES) {
const nextChunk = takeChunk()
if (nextChunk) {
if (shouldDelayFinalSeq) {
if (delayedChunk) {
yield { bytes: encodeTerminalStreamText(delayedChunk.text) }
}
delayedChunk = nextChunk
} else {
yield { bytes: encodeTerminalStreamText(nextChunk.text), seq: nextChunk.seq }
}
}
}
chunk += part
chunkBytes += partBytes
offset += part.length
}
const finalChunk = takeChunk()
if (shouldDelayFinalSeq) {
if (finalChunk) {
if (delayedChunk) {
yield { bytes: encodeTerminalStreamText(delayedChunk.text) }
}
delayedChunk = finalChunk
}
if (delayedChunk) {
yield { bytes: encodeTerminalStreamText(delayedChunk.text), seq: meta.seq }
}
return
}
if (finalChunk) {
yield { bytes: encodeTerminalStreamText(finalChunk.text), seq: finalChunk.seq }
}
}
type FrameShape = { base64: string; seq: number | 'undefined'; opcode: number | 'undefined' }
function describeFrames(frames: Iterable<TerminalOutputFrameChunk>): FrameShape[] {
const out: FrameShape[] = []
for (const frame of frames) {
out.push({
base64: Buffer.from(frame.bytes).toString('base64'),
seq: frame.seq ?? 'undefined',
opcode: frame.opcode ?? 'undefined'
})
}
return out
}
function expectEquivalent(data: string, meta: TerminalOutputMeta | undefined, label: string): void {
const legacy = describeFrames(legacyIterateTerminalOutputFrameChunks(data, meta))
const next = describeFrames(iterateTerminalOutputFrameChunks(data, meta))
expect(next, label).toEqual(legacy)
}
const SURROGATE_PAIR = '\u{1f600}'
const LONE_HIGH = '\ud83d'
const LONE_LOW = '\ude00'
// Extremes of both surrogate ranges: U+10000 (D800 DC00) and U+10FFFF (DBFF DFFF).
const FIRST_ASTRAL = '\u{10000}'
const LAST_ASTRAL = '\u{10ffff}'
const SURROGATE_EDGES = [
'\ud800',
'\udbff',
'\udc00',
'\udfff',
FIRST_ASTRAL,
LAST_ASTRAL,
'\udbff\udbff',
'\ud800\udbff',
'\udfff\udc00'
]
// Meta shapes exercised against every fixture: no meta, seq-preserved (rawLength ===
// data.length), the delayed-final-seq path (rawLength !== data.length -> OutputSpan),
// transformed, and cwd-only.
function metaShapesFor(data: string): { label: string; meta: TerminalOutputMeta | undefined }[] {
return [
{ label: 'no-meta', meta: undefined },
{ label: 'seq-only', meta: { seq: 5_000_000 } },
{ label: 'seq+rawLength=len', meta: { seq: 9_000, rawLength: data.length } },
{ label: 'seq+rawLength!=len', meta: { seq: 9_000, rawLength: data.length + 7 } },
{ label: 'rawLength!=len only', meta: { rawLength: data.length + 3 } },
{ label: 'transformed', meta: { seq: 42, rawLength: data.length, transformed: true } },
{ label: 'cwd-only', meta: { cwd: '/home/dev/orca' } },
{ label: 'seq=0', meta: { seq: 0, rawLength: data.length } }
]
}
function sweepAll(data: string, label: string): void {
for (const shape of metaShapesFor(data)) {
expectEquivalent(data, shape.meta, `${label} [${shape.label}]`)
}
}
// A fixture whose seq path is only observable when rawLength maps 1:1 to UTF-16
// offsets; forcing that shape is what makes the algebraic collapse testable.
function seqPreservingMeta(data: string, seq: number): TerminalOutputMeta {
return { seq, rawLength: data.length }
}
function escapeUnits(value: string): string {
const units: string[] = []
for (let index = 0; index < value.length; index += 1) {
units.push(`U+${value.charCodeAt(index).toString(16).toUpperCase()}`)
}
return units.join(' ')
}
function repeatToLength(unit: string, codeUnits: number): string {
let out = ''
while (out.length < codeUnits) {
out += unit
}
return out.slice(0, out.length - (out.length % unit.length))
}
// Deterministic PRNG so a fuzz failure is reproducible from the seed alone.
function makeRandom(seed: number): () => number {
let state = seed >>> 0 || 1
return () => {
state ^= state << 13
state >>>= 0
state ^= state >>> 17
state ^= state << 5
state >>>= 0
return state / 0x1_0000_0000
}
}
const FUZZ_ALPHABET = [
'a',
'z',
'\r',
'\n',
'\u00e9',
'\u20ac',
SURROGATE_PAIR,
LONE_HIGH,
LONE_LOW,
'\u0000',
'\u001b'
]
function randomText(random: () => number, parts: number): string {
let out = ''
for (let index = 0; index < parts; index += 1) {
out += FUZZ_ALPHABET[Math.floor(random() * FUZZ_ALPHABET.length)]
}
return out
}
describe('iterateTerminalOutputFrameChunks equivalence with the pre-optimization loop', () => {
it('matches on the small/no-chunking sizes', () => {
for (const size of [0, 1, 2, 3, 7, 64, 1024, TERMINAL_STREAM_CHUNK_BYTES - 1]) {
sweepAll('a'.repeat(size), `ascii ${size}`)
}
})
it('matches across 1B..200KiB ASCII payloads', () => {
for (const size of [
1,
100,
TERMINAL_STREAM_CHUNK_BYTES,
TERMINAL_STREAM_CHUNK_BYTES + 1,
TERMINAL_STREAM_CHUNK_BYTES * 2,
TERMINAL_STREAM_CHUNK_BYTES * 3 + 17,
100 * 1024,
200 * 1024
]) {
sweepAll('x'.repeat(size), `ascii ${size}`)
}
})
it('preserves legacy addition rounding for unsafe sequence values', () => {
const data = 'x'.repeat(TERMINAL_STREAM_CHUNK_BYTES + 1)
const seq = 9_007_199_254_740_994
const meta = seqPreservingMeta(data, seq)
expectEquivalent(data, meta, 'unsafe seq rounding')
const frames = [...iterateTerminalOutputFrameChunks(data, meta)]
expect(frames.at(-1)?.seq).toBe(9_007_199_254_740_992)
})
it('matches on multi-byte-only payloads at every UTF-8 width', () => {
// UTF-8 width boundaries: 1|2 at U+0080, 2|3 at U+0800, 3|4 at U+10000.
for (const unit of [
'\u00e9',
'\u20ac',
SURROGATE_PAIR,
'\u0080',
'\u07ff',
'\u0800',
'\uffff'
]) {
for (const codeUnits of [
TERMINAL_STREAM_CHUNK_BYTES / 2,
TERMINAL_STREAM_CHUNK_BYTES,
TERMINAL_STREAM_CHUNK_BYTES + 64,
90 * 1024
]) {
const data = repeatToLength(unit, codeUnits)
sweepAll(data, `unit=${JSON.stringify(unit)} codeUnits=${codeUnits}`)
}
}
})
it('matches with lone surrogates, including a trailing lone high surrogate', () => {
const filler = 'a'.repeat(TERMINAL_STREAM_CHUNK_BYTES + 5)
for (const data of [
LONE_HIGH,
LONE_LOW,
LONE_HIGH + LONE_HIGH,
LONE_LOW + LONE_HIGH,
filler + LONE_HIGH,
filler + LONE_LOW,
LONE_HIGH + filler,
LONE_LOW + filler,
filler + LONE_HIGH + filler,
// Reversed pair: never a valid pair, so both halves must stay 3-byte replacements.
filler + LONE_LOW + LONE_HIGH + filler,
repeatToLength(LONE_HIGH, 60 * 1024),
repeatToLength(LONE_LOW + LONE_HIGH, 60 * 1024)
]) {
sweepAll(data, `lone-surrogate len=${data.length}`)
}
})
it('matches at both ends of both surrogate ranges (U+D800..U+DBFF, U+DC00..U+DFFF)', () => {
const filler = 'a'.repeat(TERMINAL_STREAM_CHUNK_BYTES + 5)
for (const edge of SURROGATE_EDGES) {
for (const data of [
edge,
filler + edge,
edge + filler,
filler + edge + filler,
repeatToLength(edge, 40 * 1024)
]) {
sweepAll(data, `surrogate-edge ${escapeUnits(edge)} len=${data.length}`)
}
// Land the split inside the edge sequence itself.
for (let offset = -4; offset <= 4; offset += 1) {
const data = `${'a'.repeat(TERMINAL_STREAM_CHUNK_BYTES + offset)}${edge}${'b'.repeat(8)}`
expectEquivalent(data, undefined, `surrogate-edge ${escapeUnits(edge)} offset=${offset}`)
expectEquivalent(
data,
seqPreservingMeta(data, 500_000 + data.length),
`surrogate-edge ${escapeUnits(edge)} offset=${offset} seq`
)
}
}
})
it('sweeps a chunk boundary across a surrogate pair at CHUNK-4..CHUNK+4', () => {
for (let offset = -4; offset <= 4; offset += 1) {
const prefixBytes = TERMINAL_STREAM_CHUNK_BYTES + offset
const data = `${'a'.repeat(prefixBytes)}${SURROGATE_PAIR}${'b'.repeat(64)}`
sweepAll(data, `pair boundary offset=${offset}`)
expectEquivalent(
data,
seqPreservingMeta(data, 1_000_000 + data.length),
`pair boundary offset=${offset} seq-preserved`
)
}
})
it('sweeps a chunk boundary across a multi-byte run at CHUNK-4..CHUNK+4', () => {
for (let offset = -4; offset <= 4; offset += 1) {
const prefixBytes = TERMINAL_STREAM_CHUNK_BYTES + offset
// 3-byte run straddling the cap: the split point cannot land mid-code-point.
const data = `${'a'.repeat(prefixBytes)}${'\u20ac'.repeat(32)}${'b'.repeat(16)}`
sweepAll(data, `multibyte boundary offset=${offset}`)
}
})
it('sweeps a chunk boundary across a lone surrogate at CHUNK-4..CHUNK+4', () => {
for (let offset = -4; offset <= 4; offset += 1) {
const prefixBytes = TERMINAL_STREAM_CHUNK_BYTES + offset
for (const lone of [LONE_HIGH, LONE_LOW]) {
const data = `${'a'.repeat(prefixBytes)}${lone}${'b'.repeat(64)}`
sweepAll(data, `lone ${lone === LONE_HIGH ? 'high' : 'low'} boundary offset=${offset}`)
}
// Lone high surrogate as the very last code unit of the payload.
const trailing = `${'a'.repeat(prefixBytes)}${LONE_HIGH}`
sweepAll(trailing, `trailing lone high offset=${offset}`)
}
})
it('sweeps 2-byte and 4-byte code points across a 1-code-unit window at the cap', () => {
for (const unit of ['\u00e9', SURROGATE_PAIR]) {
for (
let pad = TERMINAL_STREAM_CHUNK_BYTES - 6;
pad <= TERMINAL_STREAM_CHUNK_BYTES + 2;
pad += 1
) {
const data = `${'a'.repeat(pad)}${unit.repeat(8)}${'z'.repeat(8)}`
expectEquivalent(data, undefined, `window unit=${unit} pad=${pad}`)
expectEquivalent(
data,
seqPreservingMeta(data, 777 + data.length),
`window unit=${unit} pad=${pad} seq`
)
expectEquivalent(
data,
{ seq: 777, rawLength: data.length + 1 },
`window unit=${unit} pad=${pad} delayed`
)
}
}
})
it('matches on the delayed-final-seq path across many chunk counts', () => {
// rawLength !== data.length routes to OutputSpan; force the multi-chunk delayed
// path by keeping rawLength === data.length but seq present with transformed=false,
// then separately assert the true delayed shape (canPreserveChunkSeq=false).
for (const chunkCount of [1, 2, 3, 5, 9]) {
const data = `${'m'.repeat(TERMINAL_STREAM_CHUNK_BYTES * chunkCount)}${SURROGATE_PAIR}tail`
expectEquivalent(data, { seq: 4242 }, `delayed chunks=${chunkCount} seq-only`)
expectEquivalent(data, { seq: 4242, rawLength: 1 }, `delayed chunks=${chunkCount} raw=1`)
expectEquivalent(data, undefined, `delayed chunks=${chunkCount} no-meta`)
}
})
it('matches on realistic mixed terminal output', () => {
const line = '\u001b[35m\u273b Thinking\u001b[0m about the \u20ac plan \u{1f600} 42 passed\r\n'
for (const repeats of [1, 200, 2000, 6000]) {
const data = line.repeat(repeats)
sweepAll(data, `mixed repeats=${repeats}`)
}
})
it('fuzzes 4000 short random payloads over the surrogate/control alphabet', () => {
const random = makeRandom(0x5eed_1234)
for (let trial = 0; trial < 4000; trial += 1) {
const data = randomText(random, Math.floor(random() * 40))
expectEquivalent(data, undefined, `fuzz-small trial=${trial}`)
expectEquivalent(
data,
{ seq: 31337, rawLength: data.length },
`fuzz-small seq trial=${trial}`
)
}
})
it('fuzzes 800 near-cap payloads whose split point lands in the random region', () => {
const random = makeRandom(0x1234_5eed)
for (let trial = 0; trial < 800; trial += 1) {
const fillerLength = TERMINAL_STREAM_CHUNK_BYTES - 6 + Math.floor(random() * 12)
const data = 'q'.repeat(fillerLength) + randomText(random, 1 + Math.floor(random() * 24))
expectEquivalent(data, undefined, `fuzz-cap trial=${trial}`)
expectEquivalent(data, { seq: 88_888, rawLength: data.length }, `fuzz-cap seq trial=${trial}`)
expectEquivalent(data, { seq: 88_888 }, `fuzz-cap delayed trial=${trial}`)
}
})
it('keeps every emitted frame within the wire cap and reassembles to the input', () => {
const data = `${'a'.repeat(200 * 1024)}${SURROGATE_PAIR.repeat(4096)}${LONE_HIGH}`
const frames = [...iterateTerminalOutputFrameChunks(data, seqPreservingMeta(data, 999_999))]
expect(frames.length).toBeGreaterThan(4)
for (const frame of frames) {
expect(frame.bytes.byteLength).toBeLessThanOrEqual(TERMINAL_STREAM_CHUNK_BYTES)
}
expect(Buffer.concat(frames.map((frame) => Buffer.from(frame.bytes))).toString('utf8')).toBe(
Buffer.from(new TextEncoder().encode(data)).toString('utf8')
)
// Seqs must be strictly increasing and end at the meta high-water mark.
const seqs = frames.map((frame) => frame.seq!)
expect(seqs.every((seq, index) => index === 0 || seq > seqs[index - 1]!)).toBe(true)
expect(seqs.at(-1)).toBe(999_999)
})
it('emits exactly one frame when the payload fits the cap in bytes but not naively', () => {
// 3-byte code points: 16384 code units = 49152 bytes = exactly the cap.
const exact = '\u20ac'.repeat(TERMINAL_STREAM_CHUNK_BYTES / 3)
expect(Buffer.byteLength(exact, 'utf8')).toBe(TERMINAL_STREAM_CHUNK_BYTES)
expect([...iterateTerminalOutputFrameChunks(exact)]).toHaveLength(1)
expect([...legacyIterateTerminalOutputFrameChunks(exact)]).toHaveLength(1)
const overByOne = `${exact}a`
expect([...iterateTerminalOutputFrameChunks(overByOne)].length).toBeGreaterThan(1)
expect([...legacyIterateTerminalOutputFrameChunks(overByOne)].length).toBeGreaterThan(1)
})
// The chunking loop is only reached when rawLength === data.length and transformed
// is falsy, which makes canPreserveChunkSeq === (typeof meta.seq === 'number') and
// therefore shouldDelayFinalSeq unconditionally false. The branch survives here
// (it also survived in the pre-optimization code) purely as defence in depth.
it('never reaches the delayed-final-seq branch for any meta shape', () => {
for (const data of ['', 'a', 'abc', 'x'.repeat(200)]) {
for (const seq of [undefined, 0, 5] as (number | undefined)[]) {
for (const rawDelta of [undefined, 0, 1, -1] as (number | undefined)[]) {
for (const transformed of [undefined, false, true] as (boolean | undefined)[]) {
const meta: TerminalOutputMeta = {}
if (seq !== undefined) {
meta.seq = seq
}
if (rawDelta !== undefined) {
meta.rawLength = data.length + rawDelta
}
if (transformed !== undefined) {
meta.transformed = transformed
}
const rawLength = meta.rawLength ?? data.length
const reachesChunkLoop = !meta.transformed && rawLength === data.length
const canPreserveChunkSeq = typeof meta.seq === 'number' && rawLength === data.length
const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta.seq === 'number'
expect(reachesChunkLoop && shouldDelayFinalSeq, JSON.stringify(meta)).toBe(false)
}
}
}
}
})
})
@@ -0,0 +1,113 @@
import {
TerminalStreamOpcode,
encodeTerminalStreamJson,
encodeTerminalStreamText
} from '../../../shared/terminal-stream-protocol'
import { TERMINAL_STREAM_CHUNK_BYTES } from '../../../shared/terminal-multiplex-flow-control'
export type TerminalOutputMeta = {
seq?: number
rawLength?: number
transformed?: boolean
cwd?: string
}
export type TerminalOutputFrameChunk = {
bytes: Uint8Array<ArrayBufferLike>
seq?: number
opcode?: TerminalStreamOpcode
}
// Why: UTF-8 length is never below UTF-16 length, so a code-unit count over the cap already proves the byte count is.
function exceedsTerminalStreamChunkBytes(data: string): boolean {
return (
data.length > TERMINAL_STREAM_CHUNK_BYTES ||
Buffer.byteLength(data, 'utf8') > TERMINAL_STREAM_CHUNK_BYTES
)
}
export function* iterateTerminalOutputFrameChunks(
data: string,
meta?: TerminalOutputMeta
): Generator<TerminalOutputFrameChunk> {
const rawLength = meta?.rawLength ?? data.length
if (meta?.transformed || rawLength !== data.length) {
yield {
opcode: TerminalStreamOpcode.OutputSpan,
bytes: encodeTerminalStreamJson({ data, rawLength, transformed: true }),
seq: meta?.seq
}
return
}
if (!exceedsTerminalStreamChunkBytes(data)) {
yield { bytes: encodeTerminalStreamText(data), seq: meta?.seq }
return
}
const canPreserveChunkSeq = typeof meta?.seq === 'number' && rawLength === data.length
// Unreachable past the OutputSpan branch above (rawLength === data.length there), but kept
// as defence in depth: it is the only shape that can carry a seq the chunk offsets can't map.
const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta?.seq === 'number'
const startSeq = canPreserveChunkSeq ? meta.seq! - rawLength : undefined
let chunkStart = 0
let chunkBytes = 0
let delayedChunk: { text: string; seq?: number } | null = null
// Why two offsets instead of an accumulator: the emitted text is always the contiguous
// substring from chunkStart to end. Keep the legacy addition order for unsafe sequence values.
const takeChunk = (end: number): { text: string; seq?: number } => {
const text = data.slice(chunkStart, end)
const current = {
text,
seq: canPreserveChunkSeq ? startSeq! + chunkStart + text.length : undefined
}
chunkStart = end
chunkBytes = 0
return current
}
let index = 0
while (index < data.length) {
const code = data.charCodeAt(index)
let width = 1
let partBytes = 3
if (code < 0x80) {
partBytes = 1
} else if (code < 0x800) {
partBytes = 2
} else if (
code >= 0xd800 &&
code <= 0xdbff &&
// Past the end charCodeAt is NaN, which masks to 0 — a trailing lone surrogate stays 3 bytes.
(data.charCodeAt(index + 1) & 0xfc00) === 0xdc00
) {
partBytes = 4
width = 2
}
// Why chunkBytes > 0: keeps a code point wider than the whole cap in its own frame
// instead of splitting an empty one forever.
if (chunkBytes > 0 && chunkBytes + partBytes > TERMINAL_STREAM_CHUNK_BYTES) {
const nextChunk = takeChunk(index)
if (shouldDelayFinalSeq) {
if (delayedChunk) {
yield { bytes: encodeTerminalStreamText(delayedChunk.text) }
}
delayedChunk = nextChunk
} else {
yield { bytes: encodeTerminalStreamText(nextChunk.text), seq: nextChunk.seq }
}
}
chunkBytes += partBytes
index += width
}
// A split always advances past its boundary, so the tail after the loop is never empty.
const finalChunk = takeChunk(data.length)
if (shouldDelayFinalSeq) {
// Why: only the final frame can safely carry the high-water mark when rawLength can't map back to UTF-16 offsets.
if (delayedChunk) {
yield { bytes: encodeTerminalStreamText(delayedChunk.text) }
}
yield { bytes: encodeTerminalStreamText(finalChunk.text), seq: meta.seq }
return
}
yield { bytes: encodeTerminalStreamText(finalChunk.text), seq: finalChunk.seq }
}
@@ -0,0 +1,393 @@
import { describe, expect, it } from 'vitest'
import {
MIN_NATIVE_BYTE_LENGTH_CODE_UNITS,
measureTerminalStreamByteLength,
terminalStreamByteLength,
terminalStreamByteLengthExceeds
} from './terminal-stream-byte-length'
import { TERMINAL_OUTPUT_BATCH_MAX_BYTES } from '../../../shared/terminal-multiplex-flow-control'
// Byte-for-byte copy of the pre-change implementation (shared/clipboard-text.ts
// measureClipboardTextByteLength), kept here so equivalence is checked against the
// ACTUAL old code path rather than a paraphrase of it.
function legacyUtf8ByteLengthForCodePoint(codePoint: number): number {
if (codePoint <= 0x7f) {
return 1
}
if (codePoint <= 0x7ff) {
return 2
}
if (codePoint <= 0xffff) {
return 3
}
return 4
}
function legacyMeasure(
text: string,
options: { stopAfterBytes?: number } = {}
): { byteLength: number; exceededLimit: boolean } {
const stopAfterBytes = options.stopAfterBytes
let byteLength = 0
for (let index = 0; index < text.length; index += 1) {
const codePoint = text.codePointAt(index) ?? 0
byteLength += legacyUtf8ByteLengthForCodePoint(codePoint)
if (Number.isFinite(stopAfterBytes) && byteLength > (stopAfterBytes ?? 0)) {
return { byteLength, exceededLimit: true }
}
if (codePoint > 0xffff) {
index += 1
}
}
return { byteLength, exceededLimit: false }
}
function legacyByteLength(data: string): number {
return legacyMeasure(data).byteLength
}
function legacyExceeds(data: string, maxBytes: number): boolean {
return legacyMeasure(data, { stopAfterBytes: maxBytes }).exceededLimit
}
function mulberry32(seed: number): () => number {
let state = seed >>> 0
return () => {
state = (state + 0x6d2b79f5) >>> 0
let t = state
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
// Mixed generator: ASCII, 2-byte, 3-byte, astral pairs, and LONE surrogates, so the
// fixtures exercise every branch of the legacy code-point scan.
function randomUnit(random: () => number): string {
const roll = random()
if (roll < 0.4) {
return String.fromCharCode(Math.floor(random() * 0x80))
}
if (roll < 0.55) {
return String.fromCharCode(0x80 + Math.floor(random() * 0x780))
}
if (roll < 0.72) {
return String.fromCharCode(0x800 + Math.floor(random() * 0xd000))
}
if (roll < 0.88) {
return String.fromCodePoint(0x10000 + Math.floor(random() * 0x100000))
}
return String.fromCharCode(0xd800 + Math.floor(random() * 0x800))
}
function randomString(random: () => number, maxUnits: number): string {
const count = Math.floor(random() * maxUnits)
let text = ''
for (let index = 0; index < count; index += 1) {
text += randomUnit(random)
}
return text
}
// Raw UTF-16 with no code-point discipline at all: catches anything that assumes
// well-formedness (unpaired high after high, low before high, etc).
function rawUtf16(random: () => number, maxUnits: number): string {
const count = Math.floor(random() * maxUnits)
let text = ''
for (let index = 0; index < count; index += 1) {
text += String.fromCharCode(Math.floor(random() * 0x11000))
}
return text
}
const EDGE_STRINGS = [
'',
'a',
'\u0000',
'\u007f',
'€',
'߿',
'ࠀ',
'￿',
'�',
'\u{10000}',
'\u{10ffff}',
'\ud800',
'\udfff',
'\ud800\ud800',
'\udc00\ud800',
'😀',
'😀\ud800',
'\ud800😀',
'a\ud800b',
'\r\n\u001b[0m',
'é́',
'\u{1f469}‍\u{1f4bb}',
'x'.repeat(1000),
'\u{1f600}'.repeat(300),
`${'é'.repeat(500)}\ud800`
]
describe('terminal stream byte length equivalence with the legacy code-point scan', () => {
it('matches the legacy total byte length on edge strings', () => {
for (const text of EDGE_STRINGS) {
expect(terminalStreamByteLength(text)).toBe(legacyByteLength(text))
}
})
it('matches the legacy total byte length over every Unicode code point', () => {
for (let codePoint = 0; codePoint <= 0x10ffff; codePoint += 1) {
const text = String.fromCodePoint(codePoint)
if (terminalStreamByteLength(text) !== legacyByteLength(text)) {
throw new Error(`byte length diverged at code point U+${codePoint.toString(16)}`)
}
}
expect(terminalStreamByteLength('\u{10ffff}')).toBe(4)
})
it('matches the legacy total byte length over every lone surrogate', () => {
for (let unit = 0xd800; unit <= 0xdfff; unit += 1) {
const text = `a${String.fromCharCode(unit)}b`
expect(terminalStreamByteLength(text)).toBe(legacyByteLength(text))
}
})
it('matches the legacy total byte length over fuzzed mixed and raw UTF-16 input', () => {
const random = mulberry32(0x5eed01)
for (let iteration = 0; iteration < 20000; iteration += 1) {
const text = iteration % 2 === 0 ? randomString(random, 24) : rawUtf16(random, 24)
if (terminalStreamByteLength(text) !== legacyByteLength(text)) {
throw new Error(`byte length diverged for ${JSON.stringify(text)}`)
}
}
expect(true).toBe(true)
})
it('matches the legacy exceededLimit decision across a dense (string, limit) sweep', () => {
const random = mulberry32(0xc0ffee)
let compared = 0
for (let iteration = 0; iteration < 10000; iteration += 1) {
const text = iteration % 2 === 0 ? randomString(random, 16) : rawUtf16(random, 16)
const total = legacyByteLength(text)
// Sweep every limit from below zero to past the true total so the boundary is hit exactly.
for (let limit = -2; limit <= total + 2; limit += 1) {
if (terminalStreamByteLengthExceeds(text, limit) !== legacyExceeds(text, limit)) {
throw new Error(`exceededLimit diverged for ${JSON.stringify(text)} at limit ${limit}`)
}
compared += 1
}
}
expect(compared).toBeGreaterThan(100000)
})
it('matches the legacy exceededLimit decision for non-finite and fractional limits', () => {
const random = mulberry32(0xfeed42)
for (const limit of [
Number.NaN,
Number.POSITIVE_INFINITY,
Number.NEGATIVE_INFINITY,
0.5,
2.5,
-0.5,
Number.MAX_SAFE_INTEGER
]) {
for (let iteration = 0; iteration < 400; iteration += 1) {
const text = rawUtf16(random, 12)
expect(terminalStreamByteLengthExceeds(text, limit)).toBe(legacyExceeds(text, limit))
}
for (const text of EDGE_STRINGS) {
expect(terminalStreamByteLengthExceeds(text, limit)).toBe(legacyExceeds(text, limit))
}
}
})
it('matches the legacy measurement pair, byteLength included, across a stopAfterBytes sweep', () => {
const random = mulberry32(0xa11ce)
for (let iteration = 0; iteration < 3000; iteration += 1) {
const text = iteration % 2 === 0 ? randomString(random, 20) : rawUtf16(random, 20)
const total = legacyByteLength(text)
for (let stopAfterBytes = -1; stopAfterBytes <= total + 2; stopAfterBytes += 1) {
const actual = measureTerminalStreamByteLength(text, { stopAfterBytes })
const expected = legacyMeasure(text, { stopAfterBytes })
if (
actual.byteLength !== expected.byteLength ||
actual.exceededLimit !== expected.exceededLimit
) {
throw new Error(
`measurement diverged for ${JSON.stringify(text)} at stopAfterBytes ${stopAfterBytes}`
)
}
}
}
expect(
measureTerminalStreamByteLength('\u{1f600}\u{1f600}\u{1f600}', { stopAfterBytes: 5 })
).toEqual(legacyMeasure('\u{1f600}\u{1f600}\u{1f600}', { stopAfterBytes: 5 }))
})
it('keeps the partial byteLength the legacy scan returned when the limit is exceeded', () => {
// A drop-in Buffer.byteLength would report 12 here; the legacy scan stops at 8.
const measurement = measureTerminalStreamByteLength('\u{1f600}\u{1f600}\u{1f600}', {
stopAfterBytes: 5
})
expect(measurement).toEqual({ byteLength: 8, exceededLimit: true })
})
it('matches the legacy measurement with no stopAfterBytes and with an undefined option bag', () => {
const random = mulberry32(0xb0b)
for (let iteration = 0; iteration < 2000; iteration += 1) {
const text = rawUtf16(random, 32)
expect(measureTerminalStreamByteLength(text)).toEqual(legacyMeasure(text))
expect(measureTerminalStreamByteLength(text, {})).toEqual(legacyMeasure(text, {}))
expect(measureTerminalStreamByteLength(text, { stopAfterBytes: undefined })).toEqual(
legacyMeasure(text, { stopAfterBytes: undefined })
)
}
})
// The code-unit floor routes short inputs back through the scan. Both sides of that
// boundary must stay legacy-identical, so sweep it exhaustively rather than by sampling.
it('matches the legacy result on both sides of the native-call floor', () => {
const random = mulberry32(0xf100a)
for (let units = 0; units <= MIN_NATIVE_BYTE_LENGTH_CODE_UNITS * 2; units += 1) {
for (let iteration = 0; iteration < 60; iteration += 1) {
let text = ''
while (text.length < units) {
text +=
iteration % 2 === 0
? randomUnit(random)
: String.fromCharCode(Math.floor(random() * 0x11000))
}
text = text.slice(0, units)
const total = legacyByteLength(text)
expect(terminalStreamByteLength(text)).toBe(total)
for (let limit = -1; limit <= total + 2; limit += 1) {
if (terminalStreamByteLengthExceeds(text, limit) !== legacyExceeds(text, limit)) {
throw new Error(`exceeds diverged at ${units} units, limit ${limit}`)
}
const actual = measureTerminalStreamByteLength(text, { stopAfterBytes: limit })
const expected = legacyMeasure(text, { stopAfterBytes: limit })
if (
actual.byteLength !== expected.byteLength ||
actual.exceededLimit !== expected.exceededLimit
) {
throw new Error(`measurement diverged at ${units} units, limit ${limit}`)
}
}
}
}
expect(MIN_NATIVE_BYTE_LENGTH_CODE_UNITS).toBeGreaterThan(0)
})
})
// Reproduces createTerminalOutputBatcher's byte accounting exactly, under both the
// legacy scan and the new measurement, and asserts IDENTICAL flush boundaries. This is
// what makes the partial-count behaviour provably unobservable at that call site.
function simulateBatcherFlushes(
chunks: string[],
measure: (
data: string,
options: { stopAfterBytes?: number }
) => { byteLength: number; exceededLimit: boolean }
): string[] {
const flushes: string[] = []
let pending: string[] = []
let bytes = 0
const flush = (): void => {
if (pending.length === 0) {
return
}
flushes.push(pending.join(''))
pending = []
bytes = 0
}
for (const data of chunks) {
if (!data) {
continue
}
pending.push(data)
const remainingBudget = Math.max(1, TERMINAL_OUTPUT_BATCH_MAX_BYTES - bytes)
const measurement = measure(data, { stopAfterBytes: remainingBudget })
bytes += measurement.byteLength
if (measurement.exceededLimit || bytes >= TERMINAL_OUTPUT_BATCH_MAX_BYTES) {
flush()
}
}
flush()
return flushes
}
describe('terminal output batcher flush boundaries are unchanged', () => {
it(
'produces identical flush boundaries over randomized multi-chunk runs',
{ timeout: 60000 },
() => {
const random = mulberry32(0x1337)
for (let run = 0; run < 300; run += 1) {
const chunks: string[] = []
const chunkCount = 1 + Math.floor(random() * 40)
for (let index = 0; index < chunkCount; index += 1) {
// Sizes straddle the 64KiB batch cap so single chunks both fit and blow the budget.
// Sizes straddle the native-call floor too, so runs mix scan-branch and
// native-branch measurements inside one batcher's byte accounting.
const scale = random()
const maxUnits =
scale < 0.25
? MIN_NATIVE_BYTE_LENGTH_CODE_UNITS * 2
: scale < 0.5
? 64
: scale < 0.85
? 20000
: 90000
chunks.push(random() < 0.5 ? randomString(random, maxUnits) : rawUtf16(random, maxUnits))
}
const legacyFlushes = simulateBatcherFlushes(chunks, legacyMeasure)
const actualFlushes = simulateBatcherFlushes(chunks, measureTerminalStreamByteLength)
if (legacyFlushes.length !== actualFlushes.length) {
throw new Error(`flush count diverged on run ${run}`)
}
for (let index = 0; index < legacyFlushes.length; index += 1) {
if (legacyFlushes[index] !== actualFlushes[index]) {
throw new Error(`flush ${index} diverged on run ${run}`)
}
}
}
expect(true).toBe(true)
}
)
it('exercises runs that actually cross the batch budget', () => {
const oversized = '\u{1f600}'.repeat(TERMINAL_OUTPUT_BATCH_MAX_BYTES)
const chunks = ['a'.repeat(10), oversized, 'b'.repeat(10), oversized, 'c']
const legacyFlushes = simulateBatcherFlushes(chunks, legacyMeasure)
expect(legacyFlushes.length).toBeGreaterThan(1)
expect(simulateBatcherFlushes(chunks, measureTerminalStreamByteLength)).toEqual(legacyFlushes)
})
})
// trimPendingOutputCoveredBySnapshot re-measures a sliced chunk with terminalStreamByteLength.
// The RPC suites never reach that slice branch (a `data.length` mutant there survives on
// unmodified HEAD too), so pin the byte accounting the branch depends on here.
describe('resync trim byte accounting for a snapshot-sliced chunk', () => {
it('re-measures a sliced chunk in UTF-8 bytes, not UTF-16 code units', () => {
const data = '\u{1f600}é走a'
const sliced = data.slice(2)
expect(terminalStreamByteLength(sliced)).toBe(legacyByteLength(sliced))
// Guards the mutant: code-unit length would be 4 here, UTF-8 is 6.
expect(terminalStreamByteLength(sliced)).toBe(6)
expect(terminalStreamByteLength(sliced)).not.toBe(sliced.length)
})
it('matches the legacy byte length for every suffix slice of multi-byte terminal text', () => {
const random = mulberry32(0x51ced)
for (let iteration = 0; iteration < 2000; iteration += 1) {
const data = iteration % 2 === 0 ? randomString(random, 24) : rawUtf16(random, 24)
for (let offset = 0; offset <= data.length; offset += 1) {
const sliced = data.slice(offset)
if (terminalStreamByteLength(sliced) !== legacyByteLength(sliced)) {
throw new Error(`sliced byte length diverged for ${JSON.stringify(data)} at ${offset}`)
}
}
}
expect(true).toBe(true)
})
})
@@ -0,0 +1,56 @@
import { measureClipboardTextByteLength } from '../../../shared/clipboard-text'
export type TerminalStreamByteLengthMeasurement = {
byteLength: number
exceededLimit: boolean
}
// UTF-8 encodes a UTF-16 code unit in at most 3 bytes (BMP scalar, or U+FFFD for a lone
// surrogate; a surrogate pair is 4 bytes across 2 units), and never in fewer than 1. So
// `length <= byteLength <= 3 * length` bounds the byte count without touching the string.
const MAX_UTF8_BYTES_PER_CODE_UNIT = 3
// Buffer.byteLength's fixed call cost (~14ns) buys nothing until it replaces enough scan
// iterations (~1.5ns each) to pay for itself; measured crossover is 8-12 code units. Below
// this the native call is a REGRESSION on interactive keystroke-echo chunks, so keep the scan.
export const MIN_NATIVE_BYTE_LENGTH_CODE_UNITS = 16
export function terminalStreamByteLength(data: string): number {
if (data.length < MIN_NATIVE_BYTE_LENGTH_CODE_UNITS) {
return measureClipboardTextByteLength(data).byteLength
}
return Buffer.byteLength(data, 'utf8')
}
export function terminalStreamByteLengthExceeds(data: string, maxBytes: number): boolean {
if (data.length === 0 || !Number.isFinite(maxBytes)) {
return false
}
// Sound: UTF-8 is never shorter than UTF-16 code-unit count, so this needs no scan at all.
if (data.length > maxBytes) {
return true
}
if (data.length < MIN_NATIVE_BYTE_LENGTH_CODE_UNITS) {
return measureClipboardTextByteLength(data, { stopAfterBytes: maxBytes }).exceededLimit
}
return Buffer.byteLength(data, 'utf8') > maxBytes
}
export function measureTerminalStreamByteLength(
data: string,
options: { stopAfterBytes?: number } = {}
): TerminalStreamByteLengthMeasurement {
const stopAfterBytes = options.stopAfterBytes
if (!Number.isFinite(stopAfterBytes)) {
return { byteLength: terminalStreamByteLength(data), exceededLimit: false }
}
// Why: over the limit the callers keep the scan's TRUNCATED running total, so only take the
// native count when the upper bound proves it can't trip the limit — otherwise both would run.
if (
data.length >= MIN_NATIVE_BYTE_LENGTH_CODE_UNITS &&
data.length * MAX_UTF8_BYTES_PER_CODE_UNIT <= (stopAfterBytes as number)
) {
return { byteLength: Buffer.byteLength(data, 'utf8'), exceededLimit: false }
}
return measureClipboardTextByteLength(data, options)
}
@@ -0,0 +1,218 @@
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getRuntimeMetadataPath, type RuntimeMetadata } from '../../shared/runtime-bootstrap'
import { clearRuntimeMetadata, readRuntimeMetadata, writeRuntimeMetadata } from './runtime-metadata'
import {
shouldReclaimRuntimeMetadata,
watchRuntimeMetadataOwnership,
type RuntimeMetadataOwnershipWatch
} from './runtime-metadata-ownership-watch'
const OWNED_PID = 4242
const OWNED_RUNTIME_ID = 'rt_owner'
const FOREIGN_LIVE_PID = 5151
const FOREIGN_DEAD_PID = 5252
function record(overrides: Partial<RuntimeMetadata> = {}): RuntimeMetadata {
return {
runtimeId: OWNED_RUNTIME_ID,
pid: OWNED_PID,
transports: [{ kind: 'unix', endpoint: '/tmp/orca-owner.sock' }],
authToken: 'secret',
startedAt: 100,
...overrides
}
}
const isProcessRunning = (pid: number): boolean => pid === OWNED_PID || pid === FOREIGN_LIVE_PID
describe('shouldReclaimRuntimeMetadata', () => {
it('leaves the record alone while it still describes this runtime', () => {
expect(
shouldReclaimRuntimeMetadata(record(), OWNED_PID, OWNED_RUNTIME_ID, isProcessRunning)
).toBe(false)
})
it('reclaims a missing record', () => {
expect(shouldReclaimRuntimeMetadata(null, OWNED_PID, OWNED_RUNTIME_ID, isProcessRunning)).toBe(
true
)
})
it('reclaims a record left behind by a dead runtime', () => {
expect(
shouldReclaimRuntimeMetadata(
record({ pid: FOREIGN_DEAD_PID, runtimeId: 'rt_second_instance' }),
OWNED_PID,
OWNED_RUNTIME_ID,
isProcessRunning
)
).toBe(true)
})
it('yields to another live runtime so two instances cannot ping-pong the record', () => {
expect(
shouldReclaimRuntimeMetadata(
record({ pid: FOREIGN_LIVE_PID, runtimeId: 'rt_second_instance' }),
OWNED_PID,
OWNED_RUNTIME_ID,
isProcessRunning
)
).toBe(false)
})
it('reclaims a foreign runtimeId stamped on this pid', () => {
// Why: only this process can be this pid, so the record is a recycled-pid leftover.
expect(
shouldReclaimRuntimeMetadata(
record({ runtimeId: 'rt_previous_process' }),
OWNED_PID,
OWNED_RUNTIME_ID,
isProcessRunning
)
).toBe(true)
})
})
describe('watchRuntimeMetadataOwnership', () => {
const watches: RuntimeMetadataOwnershipWatch[] = []
const userDataPaths: string[] = []
afterEach(() => {
for (const watch of watches.splice(0)) {
watch.stop()
}
for (const dir of userDataPaths.splice(0)) {
clearRuntimeMetadata(dir)
}
vi.useRealTimers()
})
function armWatch(userDataPath: string, pollIntervalMs = 10): RuntimeMetadataOwnershipWatch {
const watch = watchRuntimeMetadataOwnership({
userDataPath,
ownedPid: OWNED_PID,
ownedRuntimeId: OWNED_RUNTIME_ID,
pollIntervalMs,
isProcessRunning,
republish: () => writeRuntimeMetadata(userDataPath, record())
})
watches.push(watch)
return watch
}
function makeUserDataPath(): string {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-ownership-'))
userDataPaths.push(userDataPath)
return userDataPath
}
it('republishes after a second instance clobbers the record and exits', () => {
const userDataPath = makeUserDataPath()
writeRuntimeMetadata(userDataPath, record())
const watch = armWatch(userDataPath)
writeRuntimeMetadata(
userDataPath,
record({ pid: FOREIGN_DEAD_PID, runtimeId: 'rt_second_instance' })
)
watch.check()
expect(readRuntimeMetadata(userDataPath)).toMatchObject({
pid: OWNED_PID,
runtimeId: OWNED_RUNTIME_ID
})
})
it('republishes a record that was deleted underneath the runtime', () => {
const userDataPath = makeUserDataPath()
writeRuntimeMetadata(userDataPath, record())
const watch = armWatch(userDataPath)
clearRuntimeMetadata(userDataPath)
watch.check()
expect(readRuntimeMetadata(userDataPath)).toMatchObject({ pid: OWNED_PID })
})
it('replaces an unreadable record', () => {
const userDataPath = makeUserDataPath()
const watch = armWatch(userDataPath)
writeFileSync(getRuntimeMetadataPath(userDataPath), '{ truncated')
watch.check()
expect(readRuntimeMetadata(userDataPath)).toMatchObject({ pid: OWNED_PID })
})
it('leaves a live sibling runtime in place', () => {
const userDataPath = makeUserDataPath()
const watch = armWatch(userDataPath)
writeRuntimeMetadata(
userDataPath,
record({ pid: FOREIGN_LIVE_PID, runtimeId: 'rt_second_instance' })
)
watch.check()
expect(readRuntimeMetadata(userDataPath)).toMatchObject({ pid: FOREIGN_LIVE_PID })
})
it('reclaims on the poll interval without an explicit check', () => {
vi.useFakeTimers()
const userDataPath = makeUserDataPath()
armWatch(userDataPath, 1_000)
writeRuntimeMetadata(
userDataPath,
record({ pid: FOREIGN_DEAD_PID, runtimeId: 'rt_second_instance' })
)
vi.advanceTimersByTime(1_000)
expect(readRuntimeMetadata(userDataPath)).toMatchObject({ pid: OWNED_PID })
})
it('stops reclaiming once the watch is stopped', () => {
vi.useFakeTimers()
const userDataPath = makeUserDataPath()
const watch = armWatch(userDataPath, 1_000)
watch.stop()
writeRuntimeMetadata(
userDataPath,
record({ pid: FOREIGN_DEAD_PID, runtimeId: 'rt_second_instance' })
)
vi.advanceTimersByTime(5_000)
expect(readRuntimeMetadata(userDataPath)).toMatchObject({ pid: FOREIGN_DEAD_PID })
})
it('keeps polling after a republish failure', () => {
vi.useFakeTimers()
const userDataPath = makeUserDataPath()
const republish = vi
.fn()
.mockImplementationOnce(() => {
throw new Error('disk full')
})
.mockImplementation(() => writeRuntimeMetadata(userDataPath, record()))
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const watch = watchRuntimeMetadataOwnership({
userDataPath,
ownedPid: OWNED_PID,
ownedRuntimeId: OWNED_RUNTIME_ID,
pollIntervalMs: 1_000,
isProcessRunning,
republish
})
watches.push(watch)
vi.advanceTimersByTime(2_000)
expect(republish).toHaveBeenCalledTimes(2)
expect(readRuntimeMetadata(userDataPath)).toMatchObject({ pid: OWNED_PID })
consoleError.mockRestore()
})
})
@@ -0,0 +1,114 @@
import { getRuntimeMetadataPath, type RuntimeMetadata } from '../../shared/runtime-bootstrap'
import { readRuntimeMetadata } from './runtime-metadata'
/**
* Why: `orca-runtime.json` is the CLI's only pointer at a live runtime, and it
* can stop describing this process while this process is still serving RPC —
* a second instance that slipped past the single-instance lock publishes its
* own pid and then exits, leaving the CLI on a dead pid (`stale_bootstrap`)
* against a healthy app (#7848). Chromium's lock is defeated on macOS whenever
* `SingletonSocket`/`SingletonCookie` go missing, and its socket lives under
* `$TMPDIR` (`/var/folders/.../T`), which macOS itself purges after 3 days.
*
* The owner therefore watches its own record and reclaims it once no live
* runtime is described. Reclaiming only a dead pid is deliberate: two live
* runtimes sharing a profile would otherwise ping-pong the file forever.
*/
export const RUNTIME_METADATA_OWNERSHIP_POLL_MS = 10_000
export type RuntimeMetadataOwnershipWatch = {
/** Runs one ownership check immediately; exposed for tests and eager repair. */
check: () => void
stop: () => void
}
export type RuntimeMetadataOwnershipWatchOptions = {
userDataPath: string
ownedPid: number
ownedRuntimeId: string
republish: () => void
pollIntervalMs?: number
isProcessRunning?: (pid: number) => boolean
onReclaim?: (previous: RuntimeMetadata | null) => void
}
export function shouldReclaimRuntimeMetadata(
current: RuntimeMetadata | null,
ownedPid: number,
ownedRuntimeId: string,
isProcessRunning: (pid: number) => boolean
): boolean {
if (!current) {
return true
}
if (current.pid === ownedPid && current.runtimeId === ownedRuntimeId) {
return false
}
// Why: only this process can legitimately claim this pid, so a foreign
// runtimeId on it is a leftover from a recycled pid, not a live sibling.
if (current.pid === ownedPid) {
return true
}
return !isProcessRunning(current.pid)
}
export function watchRuntimeMetadataOwnership(
options: RuntimeMetadataOwnershipWatchOptions
): RuntimeMetadataOwnershipWatch {
const isProcessRunning = options.isProcessRunning ?? isPidRunning
const check = (): void => {
const current = tryReadRuntimeMetadata(options.userDataPath)
if (
!shouldReclaimRuntimeMetadata(
current,
options.ownedPid,
options.ownedRuntimeId,
isProcessRunning
)
) {
return
}
try {
options.republish()
} catch (error) {
// Why: a transient write failure must not kill the watch; the next tick retries.
console.error('[runtime] Failed to reclaim runtime metadata ownership:', error)
return
}
options.onReclaim?.(current)
}
const timer = setInterval(check, options.pollIntervalMs ?? RUNTIME_METADATA_OWNERSHIP_POLL_MS)
// Why: discovery bookkeeping must never be the reason the process stays alive.
timer.unref?.()
return {
check,
stop: () => clearInterval(timer)
}
}
function tryReadRuntimeMetadata(userDataPath: string): RuntimeMetadata | null {
try {
return readRuntimeMetadata(userDataPath)
} catch (error) {
// Why: an unparseable record is as useless to the CLI as a missing one, so treat it as reclaimable.
console.warn(
`[runtime] Ignoring unreadable ${getRuntimeMetadataPath(userDataPath)}:`,
error instanceof Error ? error.message : String(error)
)
return null
}
}
function isPidRunning(pid: number): boolean {
if (!pid || pid <= 0) {
return false
}
try {
process.kill(pid, 0)
return true
} catch (error) {
// Why: only ESRCH proves the pid is gone; EPERM means a foreign owner holds it (same rule as the socket sweep).
return (error as NodeJS.ErrnoException).code !== 'ESRCH'
}
}
+76 -1
View File
@@ -11,7 +11,7 @@ import Database from '../sqlite/sync-database'
import { OrcaRuntimeService } from './orca-runtime'
import { OrchestrationDb } from './orchestration/db'
import * as runtimeMetadataModule from './runtime-metadata'
import { readRuntimeMetadata } from './runtime-metadata'
import { readRuntimeMetadata, writeRuntimeMetadata } from './runtime-metadata'
import { createRuntimeTransportMetadata, OrcaRuntimeRpcServer } from './runtime-rpc'
import { parsePairingCode } from '../../shared/pairing'
import { subscribeRemoteRuntimeRequest } from '../../shared/remote-runtime-client'
@@ -362,6 +362,81 @@ describe('OrcaRuntimeRpcServer', () => {
})
})
it('reclaims runtime metadata clobbered by a second instance that has since died', async () => {
// Why: #7848 — a launch that slips past the single-instance lock republishes
// orca-runtime.json with its own pid, so the CLI reports stale_bootstrap
// against this still-serving runtime once that instance exits.
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const runtime = new OrcaRuntimeService()
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
await server.start()
const published = readRuntimeMetadata(userDataPath)
writeRuntimeMetadata(userDataPath, {
runtimeId: 'rt_second_instance',
pid: 99999999,
transports: [{ kind: 'unix', endpoint: join(userDataPath, 'o-99999999-rt2.sock') }],
authToken: 'second-instance-token',
startedAt: 1
})
server.checkRuntimeMetadataOwnership()
expect(readRuntimeMetadata(userDataPath)).toEqual(published)
await server.stop()
})
it('leaves runtime metadata owned by a live sibling runtime untouched', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
// Why: a synthetic owned pid frees the always-alive process.pid to stand in for
// the sibling — Windows never assigns pid 1, so hardcoding it there reads as dead.
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath,
pid: 4242
})
await server.start()
writeRuntimeMetadata(userDataPath, {
runtimeId: 'rt_live_sibling',
pid: process.pid,
transports: [{ kind: 'unix', endpoint: join(userDataPath, `o-${process.pid}-rt2.sock`) }],
authToken: 'sibling-token',
startedAt: 1
})
server.checkRuntimeMetadataOwnership()
expect(readRuntimeMetadata(userDataPath)).toMatchObject({ runtimeId: 'rt_live_sibling' })
await server.stop()
})
it('stops reclaiming runtime metadata after the server is stopped', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const server = new OrcaRuntimeRpcServer({ runtime: new OrcaRuntimeService(), userDataPath })
await server.start()
const watch = server['metadataOwnershipWatch']
if (!watch) {
throw new Error('start() must arm the metadata ownership watch')
}
// Why: the republish guard alone would keep this test green, so assert the timer teardown itself.
const watchStop = vi.spyOn(watch, 'stop')
await server.stop()
writeRuntimeMetadata(userDataPath, {
runtimeId: 'rt_second_instance',
pid: 99999999,
transports: [],
authToken: 'second-instance-token',
startedAt: 1
})
server.checkRuntimeMetadataOwnership()
expect(watchStop).toHaveBeenCalledTimes(1)
expect(server['metadataOwnershipWatch']).toBeNull()
expect(readRuntimeMetadata(userDataPath)).toMatchObject({ runtimeId: 'rt_second_instance' })
})
it('creates a pairing offer for the active WebSocket transport', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const runtime = new OrcaRuntimeService()
+38 -1
View File
@@ -6,6 +6,11 @@ import { join } from 'node:path'
import type { RuntimeMetadata, RuntimeTransportMetadata } from '../../shared/runtime-bootstrap'
import type { OrcaRuntimeService } from './orca-runtime'
import { writeRuntimeMetadata } from './runtime-metadata'
import {
RUNTIME_METADATA_OWNERSHIP_POLL_MS,
watchRuntimeMetadataOwnership,
type RuntimeMetadataOwnershipWatch
} from './runtime-metadata-ownership-watch'
import { RpcDispatcher } from './rpc/dispatcher'
import type { RpcRequest, RpcResponse } from './rpc/core'
import { errorResponse } from './rpc/errors'
@@ -57,6 +62,8 @@ type OrcaRuntimeRpcServerOptions = {
// Why: test-only overrides for the two constants below; production must not pass these (defaults set by §3.1).
keepaliveIntervalMs?: number
longPollCap?: number
// Why: test-only override for the ownership reclaim cadence.
metadataOwnershipPollMs?: number
}
export type PairingOfferUnavailableReason =
@@ -450,6 +457,7 @@ export class OrcaRuntimeRpcServer {
private readonly authToken = randomBytes(24).toString('hex')
private readonly keepaliveIntervalMs: number
private readonly longPollCap: number
private readonly metadataOwnershipPollMs: number
private readonly askLongPollCap: number
private readonly relayRevokeOutbox: RelayRevokeOutbox
private deviceRegistry: DeviceRegistry | null = null
@@ -458,6 +466,7 @@ export class OrcaRuntimeRpcServer {
private tlsFingerprint: string | null = null
private activeTransports: RpcTransport[] = []
private transports: RuntimeTransportMetadata[] = []
private metadataOwnershipWatch: RuntimeMetadataOwnershipWatch | null = null
private mobileSocketWiring: MobileSocketWiring | null = null
private mobileRelayPairingProvider: MobileRelayPairingProvider | null = null
private onUnpairedDeviceAuthFailure: (() => void) | null = null
@@ -485,7 +494,8 @@ export class OrcaRuntimeRpcServer {
preferPinnedWsPort = false,
webClientRoot,
keepaliveIntervalMs = KEEPALIVE_INTERVAL_MS,
longPollCap = LONG_POLL_CAP
longPollCap = LONG_POLL_CAP,
metadataOwnershipPollMs = RUNTIME_METADATA_OWNERSHIP_POLL_MS
}: OrcaRuntimeRpcServerOptions) {
this.runtime = runtime
this.dispatcher = new RpcDispatcher({ runtime })
@@ -498,6 +508,7 @@ export class OrcaRuntimeRpcServer {
this.webClientRoot = webClientRoot
this.keepaliveIntervalMs = keepaliveIntervalMs
this.longPollCap = longPollCap
this.metadataOwnershipPollMs = metadataOwnershipPollMs
// Why: derived, not configurable — the reservation must hold for whatever cap a caller picks.
this.askLongPollCap = Math.max(1, Math.floor(longPollCap * ASK_LONG_POLL_SHARE))
this.relayRevokeOutbox = new RelayRevokeOutbox(userDataPath)
@@ -1003,12 +1014,38 @@ export class OrcaRuntimeRpcServer {
await Promise.all(activeTransports.map((t) => t.stop().catch(() => {}))).catch(() => {})
throw error
}
this.metadataOwnershipWatch = watchRuntimeMetadataOwnership({
userDataPath: this.userDataPath,
ownedPid: this.pid,
ownedRuntimeId: this.runtime.getRuntimeId(),
pollIntervalMs: this.metadataOwnershipPollMs,
republish: () => {
// Why: never advertise endpoints we already tore down.
if (this.activeTransports.length === 0) {
return
}
this.writeMetadata()
},
onReclaim: (previous) => {
console.warn(
`[runtime] Reclaimed orca-runtime.json from a dead runtime (pid ${previous?.pid ?? 'none'}); republished pid ${this.pid}.`
)
}
})
}
/** Why: test-only seam — runs one ownership check instead of waiting out the poll interval. */
checkRuntimeMetadataOwnership(): void {
this.metadataOwnershipWatch?.check()
}
async stop(): Promise<void> {
const transports = this.activeTransports
this.activeTransports = []
this.transports = []
this.metadataOwnershipWatch?.stop()
this.metadataOwnershipWatch = null
this.mobileSocketWiring = null
if (transports.length === 0) {
return
@@ -160,7 +160,8 @@ function installSftpHandlers(sftp: SFTPWrapper, backingRoot: string, operations:
async function startSftpWireServer(backingRoot: string): Promise<SftpWireServer> {
const operations: string[] = []
const connections = new Set<Connection>()
const hostKey = utils.generateKeyPairSync('ed25519').private
// Ed25519 keygen can produce an invalid 31-byte key; ECDSA points always start with 0x04.
const hostKey = utils.generateKeyPairSync('ecdsa', { bits: 256 }).private
const server = new Ssh2Server({ hostKeys: [hostKey] }, (connection) => {
connections.add(connection)
connection.on('error', () => {})
@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from 'vitest'
import { branchCompare, type GitExec } from './git-handler-ops'
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve
})
return { promise, resolve }
}
describe('relay branchCompare', () => {
it('launches independent Git reads before waiting for any result', async () => {
const branch = deferred<{ stdout: string; stderr: string }>()
const head = deferred<{ stdout: string; stderr: string }>()
const base = deferred<{ stdout: string; stderr: string }>()
const changes = deferred<Record<string, unknown>[]>()
const git = vi.fn<GitExec>((args) => {
if (args[0] === 'branch') {
return branch.promise
}
if (args[0] === 'rev-parse' && args.includes('HEAD')) {
return head.promise
}
if (args[0] === 'rev-parse') {
return base.promise
}
if (args[0] === 'merge-base') {
return Promise.resolve({ stdout: 'merge-base\n', stderr: '' })
}
if (args[0] === 'rev-list') {
return Promise.resolve({ stdout: '1\n', stderr: '' })
}
throw new Error(`Unexpected git command: ${args.join(' ')}`)
})
const loadBranchChanges = vi.fn(() => changes.promise)
const pending = branchCompare(git, '/repo', 'origin/main', loadBranchChanges)
await Promise.resolve()
expect(git.mock.calls.map(([args]) => args.join(' '))).toEqual([
'branch --show-current',
'rev-parse --verify HEAD',
'rev-parse --verify origin/main'
])
branch.resolve({ stdout: 'feature\n', stderr: '' })
head.resolve({ stdout: 'head\n', stderr: '' })
base.resolve({ stdout: 'base\n', stderr: '' })
await vi.waitFor(() => expect(loadBranchChanges).toHaveBeenCalledOnce())
expect(git.mock.calls.some(([args]) => args[0] === 'rev-list')).toBe(true)
changes.resolve([{ path: 'file.ts' }])
await expect(pending).resolves.toMatchObject({
summary: { compareRef: 'feature', changedFiles: 1, commitsAhead: 1, status: 'ready' }
})
})
})
+31 -33
View File
@@ -154,27 +154,29 @@ export async function branchCompare(
status: 'loading'
}
try {
const { stdout: branchOut } = await git(['branch', '--show-current'], worktreePath)
const branch = branchOut.trim()
if (branch) {
summary.compareRef = branch
}
} catch {
/* keep HEAD */
}
let headOid: string
let baseOid = ''
try {
const { stdout } = await git(['rev-parse', '--verify', 'HEAD'], worktreePath)
headOid = stdout.trim()
summary.headOid = headOid
} catch {
const readCompareRef = async (): Promise<string> => {
try {
const { stdout } = await git(['rev-parse', '--verify', baseRef], worktreePath)
baseOid = stdout.trim()
summary.baseOid = baseOid
const { stdout } = await git(['branch', '--show-current'], worktreePath)
return stdout.trim() || 'HEAD'
} catch {
return 'HEAD'
}
}
const readOid = (ref: string) =>
git(['rev-parse', '--verify', ref], worktreePath).then(
({ stdout }) => ({ ok: true as const, oid: stdout.trim() }),
(error) => ({ ok: false as const, error })
)
const [compareRef, headOidResult, baseOidResult] = await Promise.all([
readCompareRef(),
readOid('HEAD'),
readOid(baseRef)
])
summary.compareRef = compareRef
if (!headOidResult.ok) {
if (baseOidResult.ok) {
summary.baseOid = baseOidResult.oid
// Why: new remote worktrees can be on an unborn branch until the first
// commit. There are no committed branch changes yet; surfacing this as a
// compare error makes the source-control panel look broken.
@@ -182,9 +184,6 @@ export async function branchCompare(
summary.commitsAhead = 0
summary.status = 'ready'
return { summary, entries: [] }
} catch {
// Preserve the existing unborn-head message when even the base is not
// resolvable; callers cannot compare or present a useful empty state.
}
summary.status = 'unborn-head'
summary.errorMessage =
@@ -192,15 +191,15 @@ export async function branchCompare(
return { summary, entries: [] }
}
try {
const { stdout } = await git(['rev-parse', '--verify', baseRef], worktreePath)
baseOid = stdout.trim()
summary.baseOid = baseOid
} catch {
const headOid = headOidResult.oid
summary.headOid = headOid
if (!baseOidResult.ok) {
summary.status = 'invalid-base'
summary.errorMessage = `Base ref ${baseRef} could not be resolved in this repository.`
return { summary, entries: [] }
}
const baseOid = baseOidResult.oid
summary.baseOid = baseOid
let mergeBase: string
try {
@@ -214,11 +213,10 @@ export async function branchCompare(
}
try {
const entries = await loadBranchChanges(mergeBase, headOid)
const { stdout: countOut } = await git(
['rev-list', '--count', `${baseOid}..${headOid}`],
worktreePath
)
const [entries, { stdout: countOut }] = await Promise.all([
loadBranchChanges(mergeBase, headOid),
git(['rev-list', '--count', `${baseOid}..${headOid}`], worktreePath)
])
summary.changedFiles = entries.length
summary.commitsAhead = Number.parseInt(countOut.trim(), 10) || 0
summary.status = 'ready'
+10 -8
View File
@@ -782,14 +782,16 @@ export class GitHandler {
const gitBound = this.git.bind(this)
return branchCompareOp(gitBound, worktreePath, baseRef, async (mergeBase, headOid) => {
// Why: -c core.quotePath=false keeps non-ASCII filenames as raw UTF-8; without it parseBranchDiff would get C-style octal-escaped paths.
const { stdout } = await gitBound(
['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', mergeBase, headOid],
worktreePath
)
const { stdout: numstat } = await gitBound(
['-c', 'core.quotePath=false', 'diff', '--numstat', '-M', '-C', mergeBase, headOid],
worktreePath
)
const [{ stdout }, { stdout: numstat }] = await Promise.all([
gitBound(
['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', mergeBase, headOid],
worktreePath
),
gitBound(
['-c', 'core.quotePath=false', 'diff', '--numstat', '-M', '-C', mergeBase, headOid],
worktreePath
)
])
return parseBranchDiff(stdout, parseNumstat(numstat))
})
}
+19 -9
View File
@@ -137,6 +137,10 @@ import { translate } from '@/i18n/i18n'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { getResolvedExecutionHostIdForWorktree } from '@/lib/resolved-worktree-execution-host'
import { browserWorkspaceHasRemoteOwner } from '@/runtime/remote-browser-tab-ownership'
import {
combineTerminalWorktreeParkIds,
useManualTerminalWorktreeParking
} from './terminal-pane/use-manual-terminal-worktree-parking'
const EditorPanel = lazy(() => import('./editor/EditorPanel'))
@@ -738,6 +742,15 @@ function Terminal(): React.JSX.Element | null {
const [parkedTerminalWorktreeIds, setParkedTerminalWorktreeIds] = useState<ReadonlySet<string>>(
() => new Set()
)
const manuallyParkedTerminalWorktreeIds = useManualTerminalWorktreeParking({
activeView,
renderedActiveWorktreeId
})
const effectiveParkedTerminalWorktreeIds = useMemo(
() =>
combineTerminalWorktreeParkIds(parkedTerminalWorktreeIds, manuallyParkedTerminalWorktreeIds),
[manuallyParkedTerminalWorktreeIds, parkedTerminalWorktreeIds]
)
// Tab restriction for targeted background mounts (wake/resume); a worktree absent from this map mounts all its tabs.
const backgroundMountTabIdsByWorktreeRef = useRef(new Map<string, ReadonlySet<string>>())
// Why: only cold-activation deferral (not targeted mounts, which share the map above) creates watcher coverage for every unmounted tab.
@@ -1055,9 +1068,9 @@ function Terminal(): React.JSX.Element | null {
const coldWorktreePresentationTargets = useMemo(
() =>
activeView === 'terminal' && activeTabType === 'terminal'
? parkedTerminalWorktreeIds
? effectiveParkedTerminalWorktreeIds
: new Set<string>(),
[activeTabType, activeView, parkedTerminalWorktreeIds]
[activeTabType, activeView, effectiveParkedTerminalWorktreeIds]
)
const {
presentationByScope: worktreePresentationByScope,
@@ -1088,7 +1101,7 @@ function Terminal(): React.JSX.Element | null {
const shouldMeasureHiddenWorktree =
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id)
const parked =
!isVisible && !shouldMeasureHiddenWorktree && parkedTerminalWorktreeIds.has(workspace.id)
!shouldMeasureHiddenWorktree && effectiveParkedTerminalWorktreeIds.has(workspace.id)
if (parked) {
for (const tab of tabs) {
const activityTerminalPortal = findActivityTerminalPortal(activityTerminalPortals, {
@@ -1135,7 +1148,7 @@ function Terminal(): React.JSX.Element | null {
backgroundMountRevision,
getEffectiveLayoutForWorktree,
groupsByWorktree,
parkedTerminalWorktreeIds,
effectiveParkedTerminalWorktreeIds,
pendingStartupByTabId,
renderedActiveWorktreeId,
tabsByWorktree,
@@ -2107,9 +2120,7 @@ function Terminal(): React.JSX.Element | null {
const shouldMeasureHiddenWorktree =
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id)
const shouldColdParkTerminalPanes =
!isVisible &&
!shouldMeasureHiddenWorktree &&
parkedTerminalWorktreeIds.has(workspace.id)
!shouldMeasureHiddenWorktree && effectiveParkedTerminalWorktreeIds.has(workspace.id)
return (
<WorktreeSplitSurface
key={`tab-groups-${workspace.id}`}
@@ -2159,9 +2170,8 @@ function Terminal(): React.JSX.Element | null {
const shouldMeasureHiddenWorktree =
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id)
const shouldColdParkTerminalPanes =
!isVisible &&
!shouldMeasureHiddenWorktree &&
parkedTerminalWorktreeIds.has(workspace.id)
effectiveParkedTerminalWorktreeIds.has(workspace.id)
return (
<div
key={workspace.id}
@@ -4,6 +4,7 @@ import '@testing-library/jest-dom/vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import type { DashboardCard, DashboardSnapshot } from '../../../../shared/dashboard-snapshot'
import type { RepoIcon } from '../../../../shared/repo-icon'
import { AgentKanbanBoard } from './AgentKanbanBoard'
// Stub the card and dialog so the board test stays free of xterm / Radix
@@ -11,10 +12,12 @@ import { AgentKanbanBoard } from './AgentKanbanBoard'
vi.mock('./AgentKanbanCard', () => ({
AgentKanbanCard: ({
card,
repoIcon,
now,
onOpenTerminal
}: {
card: DashboardCard
repoIcon?: RepoIcon | null
now: number
onOpenTerminal: (card: DashboardCard) => void
}) => (
@@ -23,6 +26,7 @@ vi.mock('./AgentKanbanCard', () => ({
data-bucket={card.bucket}
data-unseen={card.unseen}
data-now={now}
data-repo-icon={repoIcon === null ? 'none' : JSON.stringify(repoIcon)}
onClick={() => onOpenTerminal(card)}
>
{card.worktreeName}
@@ -70,8 +74,11 @@ function card(overrides: Partial<DashboardCard>): DashboardCard {
}
}
function renderBoard(cards: DashboardCard[]): void {
const snapshot: DashboardSnapshot = { generatedAt: 1, cards }
function renderBoard(
cards: DashboardCard[],
repoIconsByRepoId?: Record<string, RepoIcon | null>
): void {
const snapshot: DashboardSnapshot = { generatedAt: 1, cards, repoIconsByRepoId }
render(<AgentKanbanBoard snapshot={snapshot} />)
}
@@ -108,6 +115,30 @@ describe('AgentKanbanBoard', () => {
expect(screen.getByText('3 total')).toBeTruthy()
})
it('leaves every column border neutral now that cards carry the state color', () => {
renderBoard([card({ bucket: 'attention' })])
for (const column of document.querySelectorAll('section')) {
expect(column.className).toContain('border-border/60')
expect(column.className).not.toContain('amber')
}
})
it('routes each card its own repo icon', () => {
renderBoard(
[
card({ repoId: 'r1', worktreeName: 'from-r1' }),
card({ repoId: 'r2', worktreeName: 'from-r2' }),
card({ repoId: 'r3', worktreeName: 'from-r3' })
],
{ r1: { type: 'lucide', name: 'Rocket' }, r2: null }
)
expect(screen.getByText('from-r1').dataset.repoIcon).toBe('{"type":"lucide","name":"Rocket"}')
expect(screen.getByText('from-r2').dataset.repoIcon).toBe('none')
// Unknown repo → the card's own default glyph, never another repo's icon.
expect(screen.getByText('from-r3').dataset.repoIcon).toBe('none')
})
it('shows "None" for empty columns', () => {
renderBoard([card({ bucket: 'working' })])
// attention and idle are empty → two "None" placeholders.
@@ -6,7 +6,9 @@ import {
type DashboardCard,
type DashboardSnapshot
} from '../../../../shared/dashboard-snapshot'
import type { RepoIcon } from '../../../../shared/repo-icon'
import { cn } from '@/lib/utils'
import { TooltipProvider } from '@/components/ui/tooltip'
import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval'
import { AgentKanbanCard } from './AgentKanbanCard'
import { AgentTerminalDialog, type AgentRevealArgs } from './AgentTerminalDialog'
@@ -58,22 +60,20 @@ function groupByBucket(cards: DashboardCard[]): Record<DashboardBucket, Dashboar
function KanbanColumn({
bucket,
cards,
repoIconsByRepoId,
now,
onOpenTerminal
}: {
bucket: DashboardBucket
cards: DashboardCard[]
repoIconsByRepoId: Record<string, RepoIcon | null> | undefined
now: number
onOpenTerminal: (card: DashboardCard) => void
}): React.JSX.Element {
const highlight = bucket === 'attention' && cards.length > 0
return (
<section
className={cn(
'flex min-w-[264px] flex-1 flex-col rounded-xl border bg-muted/30',
highlight ? 'border-amber-500/40' : 'border-border/60'
)}
>
// Why: attention no longer tints the whole column — the cards inside carry
// their own state color, so a column border would double-signal it.
<section className="flex min-w-[264px] flex-1 flex-col rounded-xl border border-border/60 bg-muted/30">
<header className="flex items-center gap-2 px-3 py-2">
<span className="text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{bucketLabel(bucket)}
@@ -92,6 +92,7 @@ function KanbanColumn({
<AgentKanbanCard
key={card.paneKey}
card={card}
repoIcon={repoIconsByRepoId?.[card.repoId] ?? null}
now={now}
onOpenTerminal={onOpenTerminal}
/>
@@ -192,54 +193,60 @@ export function AgentKanbanBoard({
}, [dialogCard?.unseen, dialogCard?.paneKey, onAckAgent])
return (
<div className={cn('flex flex-col bg-background text-foreground', containerClassName)}>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2.5">
<h1 className="text-[13px] font-semibold">
{translate('dashboardPopout.title', 'Agents')}
</h1>
<span className="text-[11px] text-muted-foreground">
{translate('dashboardPopout.total', '{{count}} total', {
count: snapshot.cards.length
})}
</span>
{headerActions || onClose ? (
<div className="ml-auto flex items-center gap-1">
{headerActions}
{onClose ? (
<button
type="button"
onClick={onClose}
aria-label={translate('dashboardPopout.close', 'Close dashboard')}
className="rounded-sm p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<XIcon className="size-4" />
</button>
) : null}
</div>
) : null}
</div>
<div className="scrollbar-sleek flex min-h-0 flex-1 overflow-x-auto p-3">
{/* Why: columns share the window width up to a readable cap; mx-auto
// Why: the pop-out is its own React root with no app-level provider, and the
// card's repo tooltip needs one in both hosts. Nesting inside the main
// window's provider is harmless.
<TooltipProvider delayDuration={300}>
<div className={cn('flex flex-col bg-background text-foreground', containerClassName)}>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2.5">
<h1 className="text-[13px] font-semibold">
{translate('dashboardPopout.title', 'Agents')}
</h1>
<span className="text-[11px] text-muted-foreground">
{translate('dashboardPopout.total', '{{count}} total', {
count: snapshot.cards.length
})}
</span>
{headerActions || onClose ? (
<div className="ml-auto flex items-center gap-1">
{headerActions}
{onClose ? (
<button
type="button"
onClick={onClose}
aria-label={translate('dashboardPopout.close', 'Close dashboard')}
className="rounded-sm p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<XIcon className="size-4" />
</button>
) : null}
</div>
) : null}
</div>
<div className="scrollbar-sleek flex min-h-0 flex-1 overflow-x-auto p-3">
{/* Why: columns share the window width up to a readable cap; mx-auto
centers the capped board so leftover space splits evenly instead of
pooling on the right. In overflow the auto margins collapse to 0,
keeping the left edge reachable while scrolling. */}
<div className="mx-auto flex w-full max-w-[1280px] gap-3">
{DASHBOARD_BUCKET_ORDER.map((bucket) => (
<KanbanColumn
key={bucket}
bucket={bucket}
cards={grouped[bucket]}
now={now}
onOpenTerminal={handleOpenTerminal}
/>
))}
<div className="mx-auto flex w-full max-w-[1280px] gap-3">
{DASHBOARD_BUCKET_ORDER.map((bucket) => (
<KanbanColumn
key={bucket}
bucket={bucket}
cards={grouped[bucket]}
repoIconsByRepoId={snapshot.repoIconsByRepoId}
now={now}
onOpenTerminal={handleOpenTerminal}
/>
))}
</div>
</div>
<AgentTerminalDialog
card={dialogCard}
onOpenChange={handleDialogOpenChange}
onReveal={onRevealAgent}
/>
</div>
<AgentTerminalDialog
card={dialogCard}
onOpenChange={handleDialogOpenChange}
onReveal={onRevealAgent}
/>
</div>
</TooltipProvider>
)
}
@@ -4,7 +4,9 @@ import '@testing-library/jest-dom/vitest'
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import type { RepoIcon } from '../../../../shared/repo-icon'
import { i18n } from '@/i18n/i18n'
import { TooltipProvider } from '@/components/ui/tooltip'
import { AgentKanbanCard } from './AgentKanbanCard'
const agentIconRender = vi.fn()
@@ -42,6 +44,24 @@ function card(overrides: Partial<DashboardCard> = {}): DashboardCard {
}
}
function renderCard(props: {
card: DashboardCard
now: number
repoIcon?: RepoIcon | null
onOpenTerminal?: () => void
}): ReturnType<typeof render> {
return render(
<TooltipProvider>
<AgentKanbanCard
card={props.card}
repoIcon={props.repoIcon}
now={props.now}
onOpenTerminal={props.onOpenTerminal ?? vi.fn()}
/>
</TooltipProvider>
)
}
describe('AgentKanbanCard', () => {
beforeEach(async () => {
await i18n.changeLanguage('en')
@@ -53,9 +73,7 @@ describe('AgentKanbanCard', () => {
})
it('does not render an invented age when the start time is unknown', () => {
render(
<AgentKanbanCard card={card({ startedAt: 0 })} now={2_000_000_000} onOpenTerminal={vi.fn()} />
)
renderCard({ card: card({ startedAt: 0 }), now: 2_000_000_000 })
expect(screen.queryByText(/\d+d/)).not.toBeInTheDocument()
})
@@ -66,47 +84,160 @@ describe('AgentKanbanCard', () => {
dotState: 'waiting',
askSummary: 'Approve deploy?'
})
const { container, rerender } = render(
<AgentKanbanCard card={attentionCard} now={2_000} onOpenTerminal={vi.fn()} />
)
const { container, rerender } = renderCard({ card: attentionCard, now: 2_000 })
expect(screen.queryByTestId('state-dot')).not.toBeInTheDocument()
expect(container.querySelectorAll('.lucide-message-circle-question-mark')).toHaveLength(1)
rerender(
<AgentKanbanCard
card={{ ...attentionCard, askSummary: undefined }}
now={2_000}
onOpenTerminal={vi.fn()}
/>
<TooltipProvider>
<AgentKanbanCard
card={{ ...attentionCard, askSummary: undefined }}
now={2_000}
onOpenTerminal={vi.fn()}
/>
</TooltipProvider>
)
expect(screen.getByTestId('state-dot')).toBeInTheDocument()
expect(container.querySelector('.lucide-message-circle-question-mark')).toBeNull()
})
it('tints attention amber and done green, leaving every other state neutral', () => {
const { container: attention } = renderCard({
card: card({ bucket: 'attention', dotState: 'waiting' }),
now: 2_000
})
expect(attention.querySelector('button')?.className).toContain('border-amber-500/40')
cleanup()
const { container: done } = renderCard({
card: card({ bucket: 'idle', dotState: 'done' }),
now: 2_000
})
expect(done.querySelector('button')?.className).toContain('border-emerald-500/40')
cleanup()
const { container: idle } = renderCard({
card: card({ bucket: 'idle', dotState: 'idle' }),
now: 2_000
})
const idleClassName = idle.querySelector('button')?.className ?? ''
expect(idleClassName).toContain('border-border/60')
expect(idleClassName).not.toContain('emerald')
expect(idleClassName).not.toContain('amber')
})
it('heads the card with the conversation name and drops the worktree to the footer', () => {
const { container } = renderCard({
card: card({ lastUserMessage: 'ship it', conversationName: 'Sparse-checkout parser' }),
now: 2_000
})
const [header, footer] = [
container.querySelector('button')!.firstElementChild!,
container.querySelector('button')!.lastElementChild!
]
expect(header).toHaveTextContent('Sparse-checkout parser')
expect(header).not.toHaveTextContent('dashboard-review')
expect(footer).toHaveTextContent('dashboard-review')
// The message line is attributed to the user again — the name moved up.
expect(screen.getByText('You')).toBeInTheDocument()
})
it('heads the card with the worktree when no name resolves, without repeating it', () => {
const { container } = renderCard({ card: card({ lastUserMessage: 'ship it' }), now: 2_000 })
expect(screen.getAllByText('dashboard-review')).toHaveLength(1)
expect(container.querySelector('button')!.firstElementChild).toHaveTextContent(
'dashboard-review'
)
})
it('shows the repo as an icon labelled with its name instead of inline text', () => {
renderCard({
card: card(),
now: 2_000,
repoIcon: { type: 'emoji', emoji: '🐳' }
})
expect(screen.getByLabelText('Orca')).toBeInTheDocument()
expect(screen.getByText('🐳')).toBeInTheDocument()
})
it('skips structured-clone rerenders until visible card data or its age changes', () => {
const onOpenTerminal = vi.fn()
const initial = card({ startedAt: 1_000 })
const repoIcon: RepoIcon = { type: 'lucide', name: 'Rocket' }
const { rerender } = render(
<AgentKanbanCard card={initial} now={61_500} onOpenTerminal={onOpenTerminal} />
<TooltipProvider>
<AgentKanbanCard
card={initial}
repoIcon={repoIcon}
now={61_500}
onOpenTerminal={onOpenTerminal}
/>
</TooltipProvider>
)
expect(agentIconRender).toHaveBeenCalledTimes(1)
expect(screen.getByText('1m')).toBeInTheDocument()
rerender(<AgentKanbanCard card={{ ...initial }} now={62_000} onOpenTerminal={onOpenTerminal} />)
// A fresh structured clone of identical data — including the repo icon.
rerender(
<TooltipProvider>
<AgentKanbanCard
card={{ ...initial }}
repoIcon={{ ...repoIcon }}
now={62_000}
onOpenTerminal={onOpenTerminal}
/>
</TooltipProvider>
)
expect(agentIconRender).toHaveBeenCalledTimes(1)
rerender(
<AgentKanbanCard card={{ ...initial }} now={121_500} onOpenTerminal={onOpenTerminal} />
<TooltipProvider>
<AgentKanbanCard
card={{ ...initial }}
repoIcon={{ ...repoIcon }}
now={121_500}
onOpenTerminal={onOpenTerminal}
/>
</TooltipProvider>
)
expect(agentIconRender).toHaveBeenCalledTimes(2)
expect(screen.getByText('2m')).toBeInTheDocument()
})
it('updates the relative age when the UI language changes', async () => {
render(
<AgentKanbanCard card={card({ startedAt: 1_000 })} now={121_500} onOpenTerminal={vi.fn()} />
it('rerenders when the repo icon changes', () => {
const onOpenTerminal = vi.fn()
const initial = card({ startedAt: 1_000 })
const { rerender } = render(
<TooltipProvider>
<AgentKanbanCard
card={initial}
repoIcon={{ type: 'lucide', name: 'Rocket' }}
now={61_500}
onOpenTerminal={onOpenTerminal}
/>
</TooltipProvider>
)
expect(agentIconRender).toHaveBeenCalledTimes(1)
rerender(
<TooltipProvider>
<AgentKanbanCard
card={{ ...initial }}
repoIcon={{ type: 'lucide', name: 'Database' }}
now={61_500}
onOpenTerminal={onOpenTerminal}
/>
</TooltipProvider>
)
expect(agentIconRender).toHaveBeenCalledTimes(2)
})
it('updates the relative age when the UI language changes', async () => {
renderCard({ card: card({ startedAt: 1_000 }), now: 121_500 })
expect(screen.getByText('2m')).toBeInTheDocument()
await act(async () => {
@@ -4,8 +4,11 @@ import { MessageCircleQuestion } from 'lucide-react'
import { AgentIcon } from '@/lib/agent-catalog'
import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status'
import { AgentStateDot } from '@/components/AgentStateDot'
import { RepoIconGlyph } from '@/components/repo/repo-icon'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import type { RepoIcon } from '../../../../shared/repo-icon'
import { translate } from '@/i18n/i18n'
/** Compact "started N ago" (the card is glanceable — coarse units are fine). */
@@ -53,12 +56,34 @@ function sameCard(a: DashboardCard, b: DashboardCard): boolean {
a.finishedAt === b.finishedAt &&
a.stateChangedAt === b.stateChangedAt &&
a.unseen === b.unseen &&
a.askSummary === b.askSummary
a.askSummary === b.askSummary &&
a.conversationName === b.conversationName
)
}
/** Structural — the icon arrives inside a fresh structured clone each publish,
* so identity alone would re-render every card several times a second. */
function sameRepoIcon(a: RepoIcon | null | undefined, b: RepoIcon | null | undefined): boolean {
if (a === b) {
return true
}
if (!a || !b || a.type !== b.type) {
return false
}
if (a.type === 'lucide') {
return a.name === (b as typeof a).name
}
if (a.type === 'emoji') {
return a.emoji === (b as typeof a).emoji
}
const image = b as typeof a
return a.src === image.src && a.source === image.source && a.label === image.label
}
type AgentKanbanCardProps = {
card: DashboardCard
/** The card repo's icon. null renders the default folder glyph. */
repoIcon?: RepoIcon | null
now: number
/** Opens the board-level terminal dialog. The dialog is NOT owned by the
* card: bucket moves remount the card, and an embedded dialog would close
@@ -68,8 +93,23 @@ type AgentKanbanCardProps = {
/** One agent on the kanban board. Clicking opens the board's live terminal dialog. */
export const AgentKanbanCard = memo(
function AgentKanbanCard({ card, now, onOpenTerminal }: AgentKanbanCardProps): React.JSX.Element {
function AgentKanbanCard({
card,
repoIcon = null,
now,
onOpenTerminal
}: AgentKanbanCardProps): React.JSX.Element {
useTranslation()
// Why: the two outcomes worth scanning for get a tinted card — amber for
// "answer me", green for "finished, look at it". Everything else stays
// neutral so the tint keeps meaning something.
const needsYou = card.bucket === 'attention'
const isDone = card.dotState === 'done'
// Why: the session's own name heads the card. Without one the worktree is
// the best heading left — and then the footer drops it rather than say it
// twice.
const heading = card.conversationName ?? card.worktreeName
const worktreeInFooter = card.conversationName !== undefined
return (
<button
@@ -80,9 +120,13 @@ export const AgentKanbanCard = memo(
// paneKey has ':'/'/' which aren't valid in a custom-ident, so slugify.
style={{ viewTransitionName: `agentcard-${card.paneKey.replace(/[^a-zA-Z0-9]/g, '-')}` }}
className={cn(
'group flex w-full flex-col gap-1.5 rounded-lg border border-border/60 bg-card p-2.5 text-left',
'transition-colors hover:border-border hover:bg-accent/40',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
'group flex w-full flex-col gap-1.5 rounded-lg border p-2.5 text-left',
'transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
needsYou
? 'border-amber-500/40 bg-amber-500/[0.06] hover:border-amber-500/60 hover:bg-amber-500/10'
: isDone
? 'border-emerald-500/40 bg-emerald-500/[0.06] hover:border-emerald-500/60 hover:bg-emerald-500/10'
: 'border-border/60 bg-card hover:border-border hover:bg-accent/40'
)}
>
<div className="flex items-center gap-1.5">
@@ -99,7 +143,7 @@ export const AgentKanbanCard = memo(
card.unseen ? 'font-semibold text-foreground' : 'font-normal text-muted-foreground'
)}
>
{card.worktreeName}
{heading}
</span>
{/* The summary pill already carries the attention glyph. */}
{card.askSummary ? null : <AgentStateDot state={card.dotState} className="ml-auto" />}
@@ -109,6 +153,7 @@ export const AgentKanbanCard = memo(
<div className="flex flex-col gap-0.5">
{card.lastUserMessage ? (
<div className="line-clamp-1 text-[11px] leading-snug text-muted-foreground">
{/* Why: plain "You" again — the session's name now heads the card. */}
<span className="font-medium text-foreground/45">
{translate('dashboardPopout.card.you', 'You')}
</span>{' '}
@@ -128,17 +173,34 @@ export const AgentKanbanCard = memo(
<div className="line-clamp-2 text-xs leading-snug text-foreground/90">{card.task}</div>
) : null}
{/* Why: the card behind it is amber now, so the pill needs its own edge
to stay a distinct chip instead of a flat block of tint. */}
{card.askSummary ? (
<div className="flex items-start gap-1 rounded-md bg-amber-500/10 px-1.5 py-1 text-[11px] text-amber-600 dark:text-amber-400">
<div className="flex items-start gap-1 rounded-md bg-amber-500/15 px-1.5 py-1 text-[11px] text-amber-600 ring-1 ring-inset ring-amber-500/25 dark:text-amber-400">
<MessageCircleQuestion className="mt-px size-3 shrink-0" aria-hidden />
<span className="line-clamp-2">{card.askSummary}</span>
</div>
) : null}
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<span className="truncate font-mono">{card.repoName}</span>
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
{/* Why: the project reads as an icon so its name can't crowd the
worktree sitting next to it; the name lives in the tooltip. */}
<Tooltip>
<TooltipTrigger asChild>
<span
className="inline-flex size-[18px] shrink-0 items-center justify-center rounded-[5px] bg-muted-foreground/10 text-muted-foreground transition-colors group-hover:text-foreground"
aria-label={card.repoName}
>
<RepoIconGlyph repoIcon={repoIcon} className="size-3" iconClassName="size-3" />
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{card.repoName}
</TooltipContent>
</Tooltip>
{worktreeInFooter ? <span className="truncate">{card.worktreeName}</span> : null}
{displayTimestamp(card) > 0 ? (
<span className="ml-auto shrink-0 tabular-nums">
<span className="ml-auto shrink-0 pl-1 tabular-nums">
{formatStartedAgo(displayTimestamp(card), now)}
</span>
) : null}
@@ -149,6 +211,7 @@ export const AgentKanbanCard = memo(
(previous, next) =>
previous.onOpenTerminal === next.onOpenTerminal &&
sameCard(previous.card, next.card) &&
sameRepoIcon(previous.repoIcon, next.repoIcon) &&
(displayTimestamp(previous.card) <= 0 ||
formatStartedAgo(displayTimestamp(previous.card), previous.now) ===
formatStartedAgo(displayTimestamp(next.card), next.now))
@@ -114,6 +114,73 @@ describe('buildDashboardSnapshot', () => {
expect(card.unseen).toBe(true)
})
it('carries the tab conversation name and drops status-only titles', () => {
const named = buildDashboardSnapshot(
baseState({
agentStatusByPaneKey: { [PANE_KEY]: entry({}) },
tabsByWorktree: { w1: [{ ...tab(), customTitle: 'Sparse-checkout parser' }] }
}),
NOW
)
expect(named.cards[0].conversationName).toBe('Sparse-checkout parser')
// The fixture tab's title is the 'agent' placeholder — not a name.
const unnamed = buildDashboardSnapshot(
baseState({ agentStatusByPaneKey: { [PANE_KEY]: entry({}) } }),
NOW
)
expect(unnamed.cards[0].conversationName).toBeUndefined()
})
it('withholds generated titles until the setting enables them', () => {
const tabs = { w1: [{ ...tab(), generatedTitle: 'Fix the flaky pty test' }] }
const off = buildDashboardSnapshot(
baseState({ agentStatusByPaneKey: { [PANE_KEY]: entry({}) }, tabsByWorktree: tabs }),
NOW
)
expect(off.cards[0].conversationName).toBeUndefined()
const on = buildDashboardSnapshot(
baseState({
agentStatusByPaneKey: { [PANE_KEY]: entry({}) },
tabsByWorktree: tabs,
settings: { tabAutoGenerateTitle: true }
} as unknown as Partial<DashboardSnapshotState>),
NOW
)
expect(on.cards[0].conversationName).toBe('Fix the flaky pty test')
})
it('ships one icon per card-bearing repo, and none for repos without cards', () => {
const snapshot = buildDashboardSnapshot(
baseState({
repos: [
{
id: 'r1',
path: '/r1',
displayName: 'Repo One',
badgeColor: '#000',
repoIcon: { type: 'lucide', name: 'Rocket' }
},
{ id: 'r2', path: '/r2', displayName: 'Repo Two', badgeColor: '#000' }
],
worktreesByRepo: { r1: [worktree()], r2: [worktree('w2', 'wt-two')] },
agentStatusByPaneKey: { [PANE_KEY]: entry({}) }
} as unknown as Partial<DashboardSnapshotState>),
NOW
)
// r2 has a worktree but no agent card, so its icon never ships.
expect(snapshot.repoIconsByRepoId).toEqual({ r1: { type: 'lucide', name: 'Rocket' } })
})
it('records a null icon for a card-bearing repo that has none', () => {
const snapshot = buildDashboardSnapshot(
baseState({ agentStatusByPaneKey: { [PANE_KEY]: entry({}) } }),
NOW
)
expect(snapshot.repoIconsByRepoId).toEqual({ r1: null })
})
it('nulls ptyId when the layout entry points at a dead pty', () => {
const snapshot = buildDashboardSnapshot(
baseState({
@@ -5,7 +5,9 @@ import type {
DashboardCardDotState,
DashboardSnapshot
} from '../../../../shared/dashboard-snapshot'
import type { RepoIcon } from '../../../../shared/repo-icon'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import { getAgentRowConversationName } from '../../../../shared/agent-row-conversation-name'
import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
import { applyAgentRowLineage } from './agent-row-lineage'
import { lastEnteredDoneAt } from './agent-finished-timestamp'
@@ -43,6 +45,7 @@ export type DashboardSnapshotState = Pick<
| 'ptyIdsByTabId'
| 'runtimePaneTitlesByTabId'
| 'acknowledgedAgentsByPaneKey'
| 'settings'
>
function bucketForState(state: DashboardAgentRow['state']): DashboardBucket {
@@ -70,6 +73,24 @@ function nonEmpty(value: string | undefined): string | undefined {
return trimmed.length > 0 ? trimmed : undefined
}
/** Mirrors useAgentRowConversationName so the board and the sidebar label the
* same agent with the same name. */
function rowConversationName(
row: DashboardAgentRow,
generatedTitlesEnabled: boolean
): string | undefined {
const parentPaneKey = row.entry.orchestration?.parentPaneKey
// Why: a child row rendered on its parent's tab does not own that tab's name.
if (
row.lineage?.depth === 1 &&
parentPaneKey !== undefined &&
parsePaneKey(parentPaneKey)?.tabId === row.tab.id
) {
return undefined
}
return getAgentRowConversationName(row.tab, row.agentType, generatedTitlesEnabled) ?? undefined
}
/**
* Derive the serializable dashboard snapshot from the live renderer store.
* Reuses the exact per-worktree row machinery the sidebar uses
@@ -82,6 +103,8 @@ export function buildDashboardSnapshot(
now: number
): DashboardSnapshot {
const cards: DashboardCard[] = []
const repoIconsByRepoId: Record<string, RepoIcon | null> = {}
const generatedTitlesEnabled = state.settings?.tabAutoGenerateTitle === true
const activeWorktrees: {
repo: AppState['repos'][number]
worktree: AppState['worktreesByRepo'][string][number]
@@ -169,6 +192,8 @@ export function buildDashboardSnapshot(
: null
const dotState = row.state as DashboardCardDotState
const bucket = bucketForState(row.state)
// Only repos that actually contribute a card ship their icon.
repoIconsByRepoId[repo.id] = repo.repoIcon ?? null
cards.push({
paneKey: row.paneKey,
@@ -193,10 +218,11 @@ export function buildDashboardSnapshot(
unseen:
!isTitleDerived &&
(state.acknowledgedAgentsByPaneKey?.[row.paneKey] ?? 0) < row.entry.stateStartedAt,
askSummary: bucket === 'attention' ? (row.entry.interactivePrompt ?? undefined) : undefined
askSummary: bucket === 'attention' ? (row.entry.interactivePrompt ?? undefined) : undefined,
conversationName: rowConversationName(row, generatedTitlesEnabled)
})
}
}
return { generatedAt: now, cards }
return { generatedAt: now, cards, repoIconsByRepoId }
}
@@ -56,7 +56,10 @@ export function useAgentBucketCounts(): AgentBucketCounts {
ptyIdsByTabId,
runtimePaneTitlesByTabId,
// Counts do not render acknowledgement state, so avoid subscribing the sidebar to it.
acknowledgedAgentsByPaneKey: {}
acknowledgedAgentsByPaneKey: {},
// Same: counts never render a card's conversation name, so the
// generated-title gate is moot and the sidebar stays off settings.
settings: null
},
Date.now()
)
@@ -56,6 +56,7 @@ function makeSnapshotWatchState(): DashboardSnapshotWatchState {
ptyIdsByTabId: {},
runtimePaneTitlesByTabId: {},
acknowledgedAgentsByPaneKey: {},
settings: null,
agentStatusEpoch: 0
}
}
@@ -26,6 +26,8 @@ export function dashboardSnapshotInputsChanged(
state.ptyIdsByTabId !== previousState.ptyIdsByTabId ||
state.runtimePaneTitlesByTabId !== previousState.runtimePaneTitlesByTabId ||
state.acknowledgedAgentsByPaneKey !== previousState.acknowledgedAgentsByPaneKey ||
// Why: tabAutoGenerateTitle decides whether cards may show generated names.
state.settings !== previousState.settings ||
// Why: freshness can change a bucket without replacing any backing map.
state.agentStatusEpoch !== previousState.agentStatusEpoch
)
@@ -0,0 +1,126 @@
// @vitest-environment happy-dom
import { renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { useAppStore } from '@/store'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import type { GlobalSettings, Repo, TerminalTab, Worktree } from '../../../../shared/types'
import { useLiveDashboardSnapshot } from './useLiveDashboardSnapshot'
const NOW = 1_000_000_000
const TAB_ID = 'tab-1'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID)
const initialAppState = useAppStore.getInitialState()
beforeEach(() => {
useAppStore.setState(initialAppState, true)
})
afterEach(() => {
useAppStore.setState(initialAppState, true)
})
function repo(): Repo {
return {
id: 'repo-1',
path: '/repo',
displayName: 'Repo One',
badgeColor: '#000',
repoIcon: { type: 'lucide', name: 'Rocket' },
addedAt: 1
}
}
function worktree(): Worktree {
return {
id: 'wt-1',
repoId: 'repo-1',
path: '/repo/wt-1',
head: 'abc123',
branch: 'main',
isBare: false,
isMainWorktree: false,
displayName: 'wt-one',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: NOW
}
}
function tab(): TerminalTab {
return {
id: TAB_ID,
ptyId: 'pty-1',
worktreeId: 'wt-1',
title: 'agent',
customTitle: null,
generatedTitle: 'Fix the flaky pty test',
color: null,
sortOrder: 0,
createdAt: NOW
} as TerminalTab
}
function entry(): AgentStatusEntry {
return {
paneKey: PANE_KEY,
state: 'working',
prompt: 'do the thing',
updatedAt: Date.now(),
stateStartedAt: Date.now(),
stateHistory: [],
agentType: 'claude',
tabId: TAB_ID,
worktreeId: 'wt-1'
}
}
function seed(settings: Partial<GlobalSettings> | null): void {
useAppStore.setState({
repos: [repo()],
worktreesByRepo: { 'repo-1': [worktree()] },
tabsByWorktree: { 'wt-1': [tab()] },
agentStatusByPaneKey: { [PANE_KEY]: entry() },
terminalLayoutsByTabId: {
[TAB_ID]: {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' }
}
},
ptyIdsByTabId: { [TAB_ID]: ['pty-1'] },
settings: settings as GlobalSettings | null
})
}
// Why: the in-window drawer derives its own snapshot instead of receiving the
// relayed one, so anything the builder reads has to be threaded in by hand —
// a dropped slice silently blanks the field rather than failing loudly.
describe('useLiveDashboardSnapshot', () => {
it('feeds the builder the settings that gate generated conversation names', () => {
seed({ tabAutoGenerateTitle: true })
const withTitles = renderHook(() => useLiveDashboardSnapshot())
expect(withTitles.result.current.cards[0].conversationName).toBe('Fix the flaky pty test')
seed({ tabAutoGenerateTitle: false })
const withoutTitles = renderHook(() => useLiveDashboardSnapshot())
expect(withoutTitles.result.current.cards[0].conversationName).toBeUndefined()
})
it('carries repo icons through to the drawer', () => {
seed({ tabAutoGenerateTitle: false })
const { result } = renderHook(() => useLiveDashboardSnapshot())
expect(result.current.repoIconsByRepoId).toEqual({
'repo-1': { type: 'lucide', name: 'Rocket' }
})
})
})
@@ -23,6 +23,8 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot {
const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId)
const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId)
const acknowledgedAgentsByPaneKey = useAppStore((s) => s.acknowledgedAgentsByPaneKey)
// Why: gates generated tab titles in the cards' conversation names.
const settings = useAppStore((s) => s.settings)
// Why: freshness can flip a bucket without any backing map changing; the epoch
// ticks on the freshness boundary so the memo re-derives stale-decayed cards.
const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch)
@@ -43,7 +45,8 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot {
terminalLayoutsByTabId,
ptyIdsByTabId,
runtimePaneTitlesByTabId,
acknowledgedAgentsByPaneKey
acknowledgedAgentsByPaneKey,
settings
},
Date.now()
),
@@ -60,6 +63,7 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot {
ptyIdsByTabId,
runtimePaneTitlesByTabId,
acknowledgedAgentsByPaneKey,
settings,
agentStatusEpoch
]
)
@@ -1,16 +0,0 @@
type DocLinkTextNodeContext = {
marks: readonly { type: { name: string } }[]
}
type DocLinkParentContext = {
type: { spec: { code?: boolean } }
}
// Why: inline/fenced code content is literal markdown; auto-converting it would
// corrupt examples and commands that intentionally contain [[...]] text.
export function isDocLinkLiteralCodeTextNode(
node: DocLinkTextNodeContext,
parent: DocLinkParentContext | null
): boolean {
return parent?.type.spec.code === true || node.marks.some((mark) => mark.type.name === 'code')
}
@@ -0,0 +1,32 @@
export const DOC_LINK_PATTERN = /\[\[([^[\]\r\n]+)\]\]/g
const DOC_LINK_OPEN = '[['
type DocLinkTextNodeContext = {
type: { name: string }
text?: string
marks: readonly { type: { name: string } }[]
}
type DocLinkParentContext = {
type: { spec: { code?: boolean } }
}
export function isDocLinkLiteralCodeTextNode(
node: Pick<DocLinkTextNodeContext, 'marks'>,
parent: DocLinkParentContext | null
): boolean {
return parent?.type.spec.code === true || node.marks.some((mark) => mark.type.name === 'code')
}
// A link match requires the exact ASCII opener, including for non-ASCII targets.
export function canHoldDocLink(
node: DocLinkTextNodeContext,
parent: DocLinkParentContext | null
): node is DocLinkTextNodeContext & { text: string } {
return (
node.type.name === 'text' &&
!!node.text &&
node.text.includes(DOC_LINK_OPEN) &&
!isDocLinkLiteralCodeTextNode(node, parent)
)
}
@@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest'
import { isDocLinkLiteralCodeTextNode } from './rich-markdown-doc-link-code-context'
import { canHoldDocLink, DOC_LINK_PATTERN } from './rich-markdown-doc-link-scan'
function textContext(markNames: string[] = []) {
function textContext(text: string, markNames: string[] = []) {
return {
type: { name: 'text' },
text,
marks: markNames.map((name) => ({ type: { name } }))
}
}
@@ -15,16 +17,30 @@ function parentContext(code: boolean) {
}
}
describe('isDocLinkLiteralCodeTextNode', () => {
it('allows doc link conversion in ordinary prose text', () => {
expect(isDocLinkLiteralCodeTextNode(textContext(), parentContext(false))).toBe(false)
function scan(text: string, markNames: string[] = [], parentCode = false): string[] {
const node = textContext(text, markNames)
if (!canHoldDocLink(node, parentContext(parentCode))) {
return []
}
return [...node.text.matchAll(DOC_LINK_PATTERN)].map((match) => match[1])
}
describe('production doc-link scan predicate', () => {
it('finds links in ordinary prose, including non-ASCII targets', () => {
expect(scan('See [[Guide]] and [[文档/😀]].')).toEqual(['Guide', '文档/😀'])
})
it('skips doc link conversion for inline code marks', () => {
expect(isDocLinkLiteralCodeTextNode(textContext(['code']), parentContext(false))).toBe(true)
it('rejects ordinary prose without the exact opener', () => {
expect(scan('See [Guide], plain prose, and closed brackets]].')).toEqual([])
})
it('skips doc link conversion for fenced code block text', () => {
expect(isDocLinkLiteralCodeTextNode(textContext(), parentContext(true))).toBe(true)
it('skips inline and fenced code', () => {
expect(scan('`[[inline]]`', ['code'])).toEqual([])
expect(scan('[[fenced]]', [], true)).toEqual([])
})
it('does not match links across CR or LF boundaries', () => {
expect(scan('[[carriage\rreturn]]')).toEqual([])
expect(scan('[[line\nfeed]]')).toEqual([])
})
})
@@ -9,16 +9,12 @@ import {
parseMarkdownDocLink,
resolveMarkdownDocLink
} from './markdown-doc-links'
import { isDocLinkLiteralCodeTextNode } from './rich-markdown-doc-link-code-context'
import {
isReservedRichMarkdownTransportBody,
type RichMarkdownSourceTransport
} from './rich-markdown-source-transport'
import { renderRichMarkdownDocLinkHtml } from './rich-markdown-doc-link-dom'
// Why: `.matchAll()` at each call site creates a fresh iterator so the shared
// `/g` regex never leaks `lastIndex` state across nested or concurrent scans.
const DOC_LINK_PATTERN = /\[\[([^[\]\r\n]+)\]\]/g
import { canHoldDocLink, DOC_LINK_PATTERN } from './rich-markdown-doc-link-scan'
const docLinkDissolveKey = new PluginKey('docLinkDissolve')
const docLinkAutoConvertKey = new PluginKey('docLinkAutoConvert')
@@ -50,10 +46,7 @@ function buildPreviewDecorations(state: EditorState, storage: DocLinkStorage): D
const index = getDocIndex(storage)
const cursor = state.selection.from
state.doc.descendants((node, pos, parent) => {
if (node.type.name !== 'text' || !node.text) {
return
}
if (isDocLinkLiteralCodeTextNode(node, parent)) {
if (!canHoldDocLink(node, parent)) {
return
}
for (const match of node.text.matchAll(DOC_LINK_PATTERN)) {
@@ -279,10 +272,7 @@ export function createMarkdownDocLink(transport: RichMarkdownSourceTransport) {
let modified = false
newState.doc.descendants((node, pos, parent) => {
if (node.type.name !== 'text' || !node.text) {
return
}
if (isDocLinkLiteralCodeTextNode(node, parent)) {
if (!canHoldDocLink(node, parent)) {
return
}
@@ -1,8 +1,12 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Editor } from '@tiptap/core'
import type { DiffComment } from '../../../../shared/types'
import {
countRichMarkdownReviewMarkdownLines,
getRichMarkdownAnnotationButtonLeft,
getRichMarkdownAnnotationButtonTop
getRichMarkdownAnnotationButtonTop,
getRichMarkdownAnnotationHighlightRanges,
getRichMarkdownCommentAtPos
} from './rich-markdown-review-annotations'
afterEach(() => {
@@ -47,3 +51,77 @@ describe('getRichMarkdownAnnotationButtonLeft', () => {
expect(getRichMarkdownAnnotationButtonLeft(72)).toBe(40)
})
})
// Why count serializes: resolving a comment's block re-serializes the document,
// so doing it per comment made these O(comments x document). A call count is
// deterministic where a wall-clock threshold would be flaky.
describe('rich markdown annotation block reuse', () => {
function makeEditor(nodeCount: number): { editor: Editor; serializeCalls: () => number } {
let serializeCalls = 0
const content = Array.from({ length: nodeCount }, (_value, index) => ({
type: 'paragraph',
content: [{ type: 'text', text: `paragraph ${index}` }]
}))
const doc = {
forEach(callback: (node: unknown, offset: number, index: number) => void): void {
content.forEach((node, index) => callback(node, index * 10, index))
},
// The text-range search walks the doc to locate the selected text; these
// fixtures never match, so it only needs to be traversable.
nodesBetween(): void {},
content: { size: nodeCount * 10 }
}
const editor = {
getJSON: () => ({ content }),
state: { doc },
markdown: {
serialize: (value: { content?: unknown[] }) => {
serializeCalls += 1
return (value.content ?? []).map((_node, index) => `line ${index}`).join('\n')
}
}
} as unknown as Editor
return { editor, serializeCalls: () => serializeCalls }
}
function makeComments(count: number): DiffComment[] {
return Array.from(
{ length: count },
(_value, index) => ({ lineNumber: index + 1, selectedText: 'nothing-matches' }) as DiffComment
)
}
// One block build over NODE_COUNT nodes: each node serialized alone, plus each
// adjacent pair. Pinned absolutely so "both arms build twice" can't pass as equal.
const NODE_COUNT = 12
const ONE_BUILD_SERIALIZE_CALLS = NODE_COUNT + (NODE_COUNT - 1)
it('serializes the document once regardless of comment count', () => {
const single = makeEditor(NODE_COUNT)
getRichMarkdownAnnotationHighlightRanges(single.editor, makeComments(1), 0)
const many = makeEditor(NODE_COUNT)
getRichMarkdownAnnotationHighlightRanges(many.editor, makeComments(8), 0)
expect(single.serializeCalls()).toBe(ONE_BUILD_SERIALIZE_CALLS)
expect(many.serializeCalls()).toBe(ONE_BUILD_SERIALIZE_CALLS)
})
it('serializes the document once when locating the comment at a position', () => {
const single = makeEditor(NODE_COUNT)
getRichMarkdownCommentAtPos(single.editor, makeComments(1), 0, 5)
const many = makeEditor(NODE_COUNT)
getRichMarkdownCommentAtPos(many.editor, makeComments(8), 0, 5)
expect(single.serializeCalls()).toBe(ONE_BUILD_SERIALIZE_CALLS)
expect(many.serializeCalls()).toBe(ONE_BUILD_SERIALIZE_CALLS)
})
it('does no work at all with no comments', () => {
const none = makeEditor(12)
expect(getRichMarkdownAnnotationHighlightRanges(none.editor, [], 0)).toEqual([])
expect(getRichMarkdownCommentAtPos(none.editor, [], 0, 5)).toBeNull()
expect(none.serializeCalls()).toBe(0)
})
})
@@ -119,17 +119,29 @@ export function getRichMarkdownAnnotationHighlightRanges(
comments: readonly DiffComment[],
markdownSourceLineOffset: number
): RichMarkdownAnnotationHighlightRange[] {
if (comments.length === 0) {
return []
}
// Why once: block resolution re-serializes the doc; per comment it was O(n*doc).
const blocks = buildRichMarkdownCommentBlocks(editor)
return comments.flatMap((comment) =>
getRichMarkdownAnnotationHighlightRangesForComment(editor, comment, markdownSourceLineOffset)
getRichMarkdownAnnotationHighlightRangesForComment(
editor,
comment,
markdownSourceLineOffset,
blocks
)
)
}
export function getRichMarkdownAnnotationHighlightRangesForComment(
editor: Editor,
comment: DiffComment,
markdownSourceLineOffset: number
markdownSourceLineOffset: number,
// Why optional: callers looping over comments pass one shared build.
prebuiltBlocks?: RichMarkdownCommentBlock[]
): RichMarkdownAnnotationHighlightRange[] {
const blocks = buildRichMarkdownCommentBlocks(editor)
const blocks = prebuiltBlocks ?? buildRichMarkdownCommentBlocks(editor)
const selectedText = comment.selectedText?.trim()
if (!selectedText) {
return []
@@ -158,12 +170,17 @@ export function getRichMarkdownCommentAtPos(
markdownSourceLineOffset: number,
pos: number
): DiffComment | null {
if (comments.length === 0) {
return null
}
const blocks = buildRichMarkdownCommentBlocks(editor)
return (
comments.find((comment) =>
getRichMarkdownAnnotationHighlightRangesForComment(
editor,
comment,
markdownSourceLineOffset
markdownSourceLineOffset,
blocks
).some((range) => range.from <= pos && pos <= range.to)
) ?? null
)
@@ -55,6 +55,7 @@ import { getWorkspaceStatus, getWorkspaceStatusVisualMeta } from './workspace-st
import { WorktreeOpenInSubMenu } from './WorktreeOpenInMenu'
import { ProjectGroupNameDialog } from './ProjectGroupNameDialog'
import { WorktreeParentPickerPopover } from './WorktreeParentPickerPopover'
import { WorktreeDeveloperMenu } from './WorktreeDeveloperMenu'
import { getEligibleWorktreeParents } from './worktree-parent-candidates'
import { isEventTargetInsideCurrentTarget } from './worktree-card-dom-events'
import { translate } from '@/i18n/i18n'
@@ -924,6 +925,12 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
</>
) : null}
{!isMultiContext ? (
<>
<WorktreeDeveloperMenu worktreeId={worktree.id} disabled={isDeleting} />
<DropdownMenuSeparator />
</>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuItem
@@ -0,0 +1,58 @@
import type { ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { WorktreeDeveloperMenu } from './WorktreeDeveloperMenu'
type MenuItemProps = {
children?: ReactNode
onSelect?: () => void
}
const menuItems = vi.hoisted(() => ({ values: [] as MenuItemProps[] }))
const requestManualPark = vi.hoisted(() => vi.fn())
vi.mock('@/components/ui/dropdown-menu', async () => {
const React_ = await import('react')
const passthrough = ({ children }: { children?: ReactNode }) =>
React_.createElement(React_.Fragment, null, children)
return {
DropdownMenuSub: passthrough,
DropdownMenuSubContent: passthrough,
DropdownMenuSubTrigger: passthrough,
DropdownMenuItem: (props: MenuItemProps) => {
menuItems.values.push(props)
return React_.createElement(React_.Fragment, null, props.children)
}
}
})
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
vi.mock('@/lib/manual-terminal-worktree-parking', () => ({
requestManualTerminalWorktreePark: requestManualPark
}))
describe('WorktreeDeveloperMenu', () => {
beforeEach(() => {
menuItems.values = []
requestManualPark.mockReset()
})
it('shows the developer submenu with the parking action', () => {
const markup = renderToStaticMarkup(
<WorktreeDeveloperMenu worktreeId="worktree-1" disabled={false} />
)
expect(markup).toContain('Developer')
expect(markup).toContain('Park terminal')
expect(markup).toContain('lucide-code-xml')
expect(markup).toContain('lucide-square-parking')
})
it('requests parking for the context worktree', () => {
renderToStaticMarkup(<WorktreeDeveloperMenu worktreeId="worktree-1" disabled={false} />)
menuItems.values[0]?.onSelect?.()
expect(requestManualPark).toHaveBeenCalledWith('worktree-1')
})
})
@@ -0,0 +1,32 @@
import {
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger
} from '@/components/ui/dropdown-menu'
import { CodeXml, SquareParking } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { requestManualTerminalWorktreePark } from '@/lib/manual-terminal-worktree-parking'
export function WorktreeDeveloperMenu({
worktreeId,
disabled
}: {
worktreeId: string
disabled: boolean
}): React.JSX.Element {
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={disabled}>
<CodeXml className="size-3.5" />
{translate('auto.components.sidebar.WorktreeDeveloperMenu.developer', 'Developer')}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-44">
<DropdownMenuItem onSelect={() => requestManualTerminalWorktreePark(worktreeId)}>
<SquareParking className="size-3.5" />
{translate('auto.components.sidebar.WorktreeDeveloperMenu.parkTerminal', 'Park terminal')}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
)
}
@@ -0,0 +1,124 @@
// Why: a parked tab has no xterm, so this byte sidecar is the only thing that
// answers DECSET 2031. fish toggles 2031 on and off around every prompt, so
// answering the sticky "an h appeared" flag writes `?997;1n` into a shell that
// already handed the tty to a child (#9993).
import { beforeEach, describe, expect, it, vi } from 'vitest'
const ESC = '\x1b'
const PTY_ID = 'pty-parked-2031'
// One fish prompt cycle: subscribe, paint, hand off the tty.
const FISH_PROMPT_HANDOFF = `${ESC}[?2031h${ESC}[0m~/orca ${ESC}[32m❯${ESC}[0m ${ESC}[?2031l`
let sidecarWatcher: ((data: string) => void) | null = null
const unsubscribe = vi.fn()
vi.mock('./pty-data-sidecar-subscriptions', () => ({
subscribeToPtyData: (_ptyId: string, watcher: (data: string) => void) => {
sidecarWatcher = watcher
return unsubscribe
}
}))
vi.mock('@/store', () => ({
useAppStore: {
getState: () => ({ settings: { theme: 'dark' as const } })
}
}))
vi.mock('@/lib/terminal-theme', () => ({
getSystemPrefersDark: () => true
}))
const { startParkedTerminalMode2031Responder } =
await import('./parked-terminal-mode2031-responder')
function startResponder(): { sendInput: ReturnType<typeof vi.fn>; feed: (data: string) => void } {
const sendInput = vi.fn()
startParkedTerminalMode2031Responder({ ptyId: PTY_ID, sendInput })
return {
sendInput,
feed: (data: string) => sidecarWatcher?.(data)
}
}
beforeEach(() => {
sidecarWatcher = null
vi.clearAllMocks()
})
describe('parked-tab DECSET 2031 responder honors the chunk-final state (#9993)', () => {
it('does not reply to a subscribe the same chunk withdrew', () => {
const { sendInput, feed } = startResponder()
feed(FISH_PROMPT_HANDOFF)
expect(sendInput).not.toHaveBeenCalled()
})
it('replies once to a TUI that subscribes and keeps listening', () => {
const { sendInput, feed } = startResponder()
feed(`${ESC}[?2031h`)
expect(sendInput).toHaveBeenCalledTimes(1)
expect(sendInput).toHaveBeenCalledWith(`${ESC}[?997;1n`)
})
it('stays silent across a run of fish prompts', () => {
const { sendInput, feed } = startResponder()
feed(FISH_PROMPT_HANDOFF)
feed(FISH_PROMPT_HANDOFF)
feed(FISH_PROMPT_HANDOFF)
expect(sendInput).not.toHaveBeenCalled()
})
it('replies when a TUI subscribes after a prompt cycle in the same chunk', () => {
const { sendInput, feed } = startResponder()
feed(`${FISH_PROMPT_HANDOFF}${ESC}[?2031h`)
expect(sendInput).toHaveBeenCalledTimes(1)
})
it('still answers a subscribe split across two chunks', () => {
const { sendInput, feed } = startResponder()
feed(`${ESC}[?20`)
feed('31h')
expect(sendInput).toHaveBeenCalledTimes(1)
})
it('does not reply when the withdrawal is split across two chunks', () => {
const { sendInput, feed } = startResponder()
feed(`${ESC}[?2031h prompt ${ESC}[?20`)
expect(sendInput).not.toHaveBeenCalled()
feed('31l')
expect(sendInput).not.toHaveBeenCalled()
})
it('does not reply when an unrelated private mode appends a split withdrawal', () => {
const { sendInput, feed } = startResponder()
feed(`${ESC}[?2031h prompt ${ESC}[?25`)
expect(sendInput).not.toHaveBeenCalled()
feed(';2031l')
expect(sendInput).not.toHaveBeenCalled()
})
it('replies after an ambiguous tail resolves to another mode', () => {
const { sendInput, feed } = startResponder()
feed(`${ESC}[?2031h drawing ${ESC}[?20`)
expect(sendInput).not.toHaveBeenCalled()
feed('25h')
expect(sendInput).toHaveBeenCalledTimes(1)
})
})
@@ -15,9 +15,10 @@
* consumed while the gate is ON — this sidecar stays the only answerer here.
*/
import {
INITIAL_MODE_2031_REPLY_SCAN_STATE,
mode2031SequenceFor,
resolveTerminalColorSchemeMode,
scanMode2031Sequences
scanMode2031ReplyDecision
} from '../../../../shared/terminal-color-scheme-protocol'
import { useAppStore } from '@/store'
import { getSystemPrefersDark } from '@/lib/terminal-theme'
@@ -33,13 +34,11 @@ export function startParkedTerminalMode2031Responder(
options: ParkedTerminalMode2031ResponderOptions
): () => void {
const { ptyId, sendInput } = options
// Why: a DECSET 2031 subscribe can be split across PTY chunks; the scan
// carries a bounded tail between chunks so split sequences still match.
let scanTail = ''
let scanState = INITIAL_MODE_2031_REPLY_SCAN_STATE
return subscribeToPtyData(ptyId, (data) => {
const scan = scanMode2031Sequences(scanTail, data)
scanTail = scan.tail
if (!scan.subscribe) {
const result = scanMode2031ReplyDecision(scanState, data)
scanState = result.state
if (result.decision !== 'subscribed') {
return
}
// Why: reply with the resolved theme so TUIs that subscribe while parked
@@ -6631,6 +6631,53 @@ describe('connectPanePty', () => {
expect(deps.updateTabPtyId).not.toHaveBeenCalledWith('tab-1', otherTabPtyId)
})
it('fresh-spawns a shell into any PTY-less tab, so agent launches must never publish one', async () => {
// Why: #2989 depends on PTY-less tabs taking this legitimate fresh-shell path.
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
transport.connect.mockImplementation(async (opts: { sessionId?: string }) => {
if (opts.sessionId) {
return { id: opts.sessionId }
}
const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as
| ((ptyId: string) => void)
| undefined
onPtySpawn?.('stray-shell-pty')
return 'stray-shell-pty'
})
transportFactoryQueue.push(transport)
// Reproduce the pre-fix gap between createTab and PTY binding.
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
ptyIdsByTabId: { 'tab-1': [] },
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf', leafId: LEAF_1 },
activeLeafId: LEAF_1,
expandedLeafId: null
}
},
agentLaunchConfigByPaneKey: {
[`tab-1:${LEAF_1}`]: {
launchConfig: { agentCommand: 'claude', agentArgs: '', agentEnv: {} },
identity: { agentType: 'claude' }
}
}
} as StoreState
const deps = createDeps()
const pane = createPane(1)
connectPanePty(pane as never, createManager(1) as never, deps as never)
await flushAsyncTicks()
// Launch registration alone cannot identify a PTY to attach.
expect(transport.connect).toHaveBeenCalledWith(
expect.objectContaining({ url: '', cols: expect.any(Number) })
)
expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'stray-shell-pty')
})
it('spawns a fresh PTY when a restored daemon split session cannot reattach', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
@@ -9923,14 +9970,62 @@ describe('connectPanePty', () => {
expect(recordPaneMode2031Subscription).toHaveBeenCalledWith(1, 'dark')
})
it('reports the gate-managed predicate on the binding for the xterm 2031 observer', async () => {
it('retires the fact-registered subscription when the TUI withdraws it', async () => {
// The counterpart to the test above. A gated pane never sees the withdrawal bytes
// (main drops them) and both the chunk scanner and xterm's CSI handler are disabled
// for it, so this fact is the only observer that can retire the subscription. Left
// registered, the next theme flip pushes CSI 997 at the shell that replaced the TUI.
enableMainAuthority()
const paneMode2031Ref = { current: new Map<number, boolean>() }
const paneLastThemeModeRef = { current: new Map<number, 'dark' | 'light'>() }
const deps = createDeps({
isVisibleRef: { current: false },
paneMode2031Ref,
paneLastThemeModeRef,
// Exactly what use-terminal-pane-lifecycle wires up for this callback.
recordPaneMode2031Subscription: (paneId: number, repliedMode: 'dark' | 'light') => {
paneMode2031Ref.current.set(paneId, true)
paneLastThemeModeRef.current.set(paneId, repliedMode)
}
})
await connectHiddenPane(deps)
const transportOptions = createdTransportOptions.at(-1) as {
onPtySpawn?: (ptyId: string) => void
}
transportOptions.onPtySpawn?.('pty-id')
const factsHandler = await import('./terminal-side-effect-facts-handler')
factsHandler._dispatchTerminalSideEffectBatchForTest({
ptyId: 'pty-id',
seq: 12,
facts: [{ kind: '2031-subscribe' }]
})
expect(paneMode2031Ref.current.get(1)).toBe(true)
factsHandler._dispatchTerminalSideEffectBatchForTest({
ptyId: 'pty-id',
seq: 24,
facts: [{ kind: '2031-unsubscribe' }]
})
expect(paneMode2031Ref.current.get(1)).toBeUndefined()
expect(paneLastThemeModeRef.current.get(1)).toBeUndefined()
})
it('leaves the chunk scanner silent on a gate-managed PTY', async () => {
// Main's '2031-subscribe' fact already answers these; a chunk-boundary reply here
// would answer the same subscribe a second time.
enableMainAuthority()
const deps = createDeps({ isVisibleRef: { current: false } })
const { binding } = await connectHiddenPane(deps)
const bindingWithPredicate = binding as typeof binding & {
isHiddenDeliveryGateManagedPty: () => boolean
}
expect(bindingWithPredicate.isHiddenDeliveryGateManagedPty()).toBe(true)
const { transport, dataCallback } = await connectHiddenPane(deps)
const before = transport.sendInput.mock.calls.length
dataCallback('\x1b[?2031h')
const replies = transport.sendInput.mock.calls
.slice(before)
.flat()
.filter((arg) => String(arg).includes('997'))
expect(replies).toEqual([])
})
it('declares hidden-at-spawn on connect for hidden panes', async () => {
@@ -9970,13 +10065,7 @@ describe('connectPanePty', () => {
terminalHiddenDeliveryGate: false
} as StoreState['settings']
const deps = createDeps({ isVisibleRef: { current: false } })
const { transport, dataCallback, binding } = await connectHiddenPane(deps)
// Why: the lifecycle's xterm CSI observer consults this predicate — kill switch off keeps the legacy xterm reply path.
expect(
(
binding as typeof binding & { isHiddenDeliveryGateManagedPty: () => boolean }
).isHiddenDeliveryGateManagedPty()
).toBe(false)
const { transport, dataCallback } = await connectHiddenPane(deps)
const transportOptions = createdTransportOptions.at(-1) as {
onPtySpawn?: (ptyId: string) => void
}
@@ -10244,10 +10333,11 @@ describe('connectPanePty', () => {
pane: ReturnType<typeof createPane>
dataCallback: (data: string, meta?: { seq?: number; rawLength?: number }) => void
getMainBufferSnapshot: ReturnType<typeof vi.fn>
transport: MockTransport
}> {
enableMainAuthority()
const deps = createDeps({ isVisibleRef: { current: true } })
const { pane, dataCallback } = await connectHiddenPane(deps)
const { pane, dataCallback, transport } = await connectHiddenPane(deps)
const transportOptions = createdTransportOptions.at(-1) as {
onPtySpawn?: (ptyId: string) => void
}
@@ -10275,7 +10365,7 @@ describe('connectPanePty', () => {
expect.any(Function)
)
pane.terminal.write.mockClear()
return { pane, dataCallback, getMainBufferSnapshot }
return { pane, dataCallback, getMainBufferSnapshot, transport }
}
function writtenData(pane: ReturnType<typeof createPane>): string {
@@ -10296,6 +10386,26 @@ describe('connectPanePty', () => {
expect(writtenData(pane)).toContain('NEW')
})
it('answers a 2031 subscribe on a chunk the restored snapshot drops as duplicate', async () => {
// Why the scan runs before reconciliation: the snapshot restore replays bytes
// into xterm without answering queries, so this live delivery is the only
// chance to reply. Dropping the chunk as a duplicate must not drop the query.
// Gate off so the chunk scanner (not main's fact) owns this pane's reply.
enableMainAuthority()
mockStoreState.settings = {
...mockStoreState.settings,
terminalHiddenDeliveryGate: false
} as StoreState['settings']
const { pane, dataCallback, transport } = await restoreVisiblePaneToBaseline()
transport.sendInput.mockClear()
dataCallback(`SUB\x1b[?2031h`, { seq: 60, rawLength: 11 })
await flushAsyncTicks(8)
expect(writtenData(pane)).not.toContain('SUB')
expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;1n')
})
it('slices a partial overlap when raw and clean lengths match', async () => {
const { pane, dataCallback } = await restoreVisiblePaneToBaseline()
@@ -10987,7 +11097,7 @@ describe('connectPanePty', () => {
binding.dispose()
})
it('writes mode 2031 through hidden xterm instead of side-channel answering it', async () => {
it('answers a mode 2031 subscribe once, from the raw chunk boundary', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
@@ -11015,7 +11125,11 @@ describe('connectPanePty', () => {
capturedDataCallback.current?.('\x1b[?2031h')
vi.advanceTimersByTime(50)
expect(transport.sendInput).not.toHaveBeenCalled()
// Why the scanner and not xterm's CSI handler: xterm batches PTY chunks into one
// parse, so only this layer knows the chunk ended still subscribed (#9993).
expect(transport.sendInput).toHaveBeenCalledTimes(1)
expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;2n')
// The bytes still reach xterm so the emulator tracks the mode itself.
expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[?2031h')
} finally {
vi.useRealTimers()
@@ -11024,6 +11138,168 @@ describe('connectPanePty', () => {
binding.dispose()
})
// Why these three: fish 4.7.1 enables and disables 2031 around *every* prompt with no
// opt-out, so back-to-back chunks each carrying a toggle are the normal case, not an edge
// case. Each chunk owes exactly one decision, and xterm cannot make it — it parses several
// chunks in one synchronous batch, so a reply deferred to the parser is either dropped or
// answered against a subscription a later chunk already withdrew (#9993).
describe('mode 2031 replies are decided per raw PTY chunk', () => {
async function connectVisiblePane(): Promise<{
transport: ReturnType<typeof createMockTransport>
deps: ReturnType<typeof createDeps>
emit: (data: string, meta?: { droppedOutput?: boolean }) => void
dispose: () => void
}> {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
const captured: {
current: ((data: string, meta?: { droppedOutput?: boolean }) => void) | null
} = { current: null }
transport.connect.mockImplementation(
async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
captured.current = callbacks.onData ?? null
return 'pty-id'
}
)
transportFactoryQueue.push(transport)
mockStoreState = {
...mockStoreState,
settings: { ...mockStoreState.settings, theme: 'light' }
}
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps({ isVisibleRef: { current: true } })
const binding = connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(6)
return {
transport,
deps,
emit: (data: string, meta?: { droppedOutput?: boolean }) => captured.current?.(data, meta),
dispose: () => binding.dispose()
}
}
const replies = (transport: { sendInput: { mock: { calls: unknown[][] } } }): unknown[] =>
transport.sendInput.mock.calls.flat().filter((arg) => String(arg).includes('997'))
it('answers a chunk that ends subscribed even when the next chunk withdraws', async () => {
const { transport, emit, dispose } = await connectVisiblePane()
// Two separate PTY chunks. xterm would parse both in one batch and see only the net
// result; the first chunk still owes a reply.
emit('\x1b[?2031h')
emit('\x1b[?2031l')
expect(replies(transport)).toEqual(['\x1b[?997;2n'])
dispose()
})
it('answers each chunk that ends subscribed', async () => {
const { transport, emit, dispose } = await connectVisiblePane()
emit('\x1b[?2031h')
emit('\x1b[?2031l\x1b[?2031h')
expect(replies(transport)).toHaveLength(2)
dispose()
})
it('stays silent when one chunk both subscribes and withdraws', async () => {
const { transport, emit, dispose } = await connectVisiblePane()
// The fish prompt case: the subscription is gone before the program could read a reply.
emit('\x1b[?2031h prompt \x1b[?2031l')
expect(replies(transport)).toEqual([])
dispose()
})
it('stays silent when the withdrawal is split across two chunks', async () => {
const { transport, emit, dispose } = await connectVisiblePane()
emit('\x1b[?2031h prompt \x1b[?20')
expect(replies(transport)).toEqual([])
emit('31l')
expect(replies(transport)).toEqual([])
dispose()
})
it('stays silent when an unrelated private mode appends a split withdrawal', async () => {
const { transport, emit, dispose } = await connectVisiblePane()
emit('\x1b[?2031h prompt \x1b[?25')
expect(replies(transport)).toEqual([])
emit(';2031l')
expect(replies(transport)).toEqual([])
dispose()
})
it('answers after an ambiguous tail resolves to another mode', async () => {
const { transport, emit, dispose } = await connectVisiblePane()
emit('\x1b[?2031h drawing \x1b[?20')
expect(replies(transport)).toEqual([])
emit('25h')
expect(replies(transport)).toEqual(['\x1b[?997;2n'])
dispose()
})
// One fish prompt cycle, exactly as fish's tty_handoff.rs emits it.
const FISH_PROMPT_HANDOFF = '\x1b[?2031h\x1b[0m~/orca \x1b[32m❯\x1b[0m \x1b[?2031l'
it('stays silent across three fish prompts', async () => {
const { transport, emit, dispose } = await connectVisiblePane()
emit(FISH_PROMPT_HANDOFF)
emit(FISH_PROMPT_HANDOFF)
emit(FISH_PROMPT_HANDOFF)
expect(replies(transport)).toEqual([])
dispose()
})
it('leaves no stale subscription behind after a fish prompt cycle', async () => {
const { deps, emit, dispose } = await connectVisiblePane()
emit(FISH_PROMPT_HANDOFF)
// A later theme flip must not push CSI 997 at a shell that unsubscribed.
expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined()
dispose()
})
it('answers once when a TUI subscribes at the end of a fish prompt chunk', async () => {
const { transport, emit, dispose } = await connectVisiblePane()
emit(`${FISH_PROMPT_HANDOFF}\x1b[?2031h`)
expect(replies(transport)).toHaveLength(1)
dispose()
})
it('answers a subscribe whose withdrawal never arrives', async () => {
const { transport, emit, dispose } = await connectVisiblePane()
// A real TUI: subscribe now, unsubscribe minutes later on exit.
emit('\x1b[?2031h')
emit('painting the ui')
expect(replies(transport)).toHaveLength(1)
dispose()
})
it('drops a live subscription when a later chunk withdraws it', async () => {
const { deps, emit, dispose } = await connectVisiblePane()
// A TUI that exits: the earlier chunk really did register, so the withdrawal has
// state to undo — otherwise a theme flip pushes CSI 997 at the shell that replaced it.
emit('\x1b[?2031h')
expect(deps.paneMode2031Ref.current.get(1)).toBe(true)
emit('\x1b[?2031l')
expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined()
expect(deps.paneLastThemeModeRef.current.get(1)).toBeUndefined()
dispose()
})
it('discards a half-read escape prefix when the pane swaps PTYs', async () => {
const { transport, emit, dispose } = await connectVisiblePane()
// Why: the tail is a byte range from the old stream. Splicing it onto the first
// chunk of a replacement PTY fabricates a subscribe no program ever sent.
transport.serializeBuffer = vi.fn().mockResolvedValue(null)
emit('\x1b[?20')
// droppedOutput latches the hidden-restore PTY id, which is what detects the swap.
emit('', { droppedOutput: true })
transport.getPtyId.mockReturnValue('pty-id-2')
emit('31h')
expect(replies(transport)).toEqual([])
dispose()
})
})
it('answers hidden Codex mode 2031 subscribes split across becoming visible', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
@@ -11605,10 +11881,11 @@ describe('connectPanePty', () => {
getMainBufferSnapshot.mockClear()
// Main hit the per-PTY pending cap and sent the droppedOutput sentinel: the stream has a gap, so repaint from the authoritative main-owned buffer.
capturedDataCallback.current?.('', { droppedOutput: true })
capturedDataCallback.current?.('\x1b[?2031h', { droppedOutput: true })
await flushAsyncTicks(20)
expect(getMainBufferSnapshot).toHaveBeenCalled()
expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;1n')
expect(pane.terminal.write).toHaveBeenCalledWith(
expect.stringContaining('healed from snapshot'),
expect.any(Function)
@@ -122,9 +122,10 @@ import { shouldUseShellReadyStartupDelivery } from '../../../../shared/codex-sta
import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing'
import { getSystemPrefersDark } from '@/lib/terminal-theme'
import {
INITIAL_MODE_2031_REPLY_SCAN_STATE,
mode2031SequenceFor,
resolveTerminalColorSchemeMode,
scanMode2031Sequences
scanMode2031ReplyDecision
} from '../../../../shared/terminal-color-scheme-protocol'
import { warnTerminalLifecycleAnomaly } from './terminal-lifecycle-diagnostics'
import { subscribeToTerminalUserInput } from './terminal-user-input-signal'
@@ -664,11 +665,6 @@ type PanePtyBinding = IDisposable & {
requestDroidReconfirmation: () => void
reconcileIfSessionDead: (liveSessionIds: Set<string>, snapshotRequestedAt?: number) => void
reconcileIfSessionMissing: (hasPty: HasPty, livenessRequestedAt?: number) => void
/** True when the hidden-delivery gate structurally manages the pane's
* current PTY. The lifecycle's xterm CSI ?2031h observer consults this to
* stay silent — main's '2031-subscribe' fact is the sole responder for
* gate-managed PTYs. */
isHiddenDeliveryGateManagedPty: () => boolean
}
function isAgentTaskCompleteNotificationEnabled(): boolean {
@@ -2251,7 +2247,10 @@ export function connectPanePty(
// Why: gated hidden panes never see the subscribe bytes; the fact
// replaces the byte scan (and the old post-latch subscribe drop).
...(hiddenDeliveryGateActive
? { onMode2031Subscribe: handleHiddenMode2031SubscribeFact }
? {
onMode2031Subscribe: handleHiddenMode2031SubscribeFact,
onMode2031Unsubscribe: handleHiddenMode2031UnsubscribeFact
}
: {})
},
restoreTitleOnRegister: true
@@ -3603,6 +3602,20 @@ export function connectPanePty(
deps.recordPaneMode2031Subscription?.(pane.id, mode)
recordHiddenMode2031Reply()
}
// Why (gate mode only): the counterpart to the subscribe fact. These panes never
// receive the withdrawal bytes — main drops them before delivery — and both the
// chunk scanner and the xterm CSI handler are disabled for them, so this fact is
// the ONLY observer that can retire the subscription. Without it a TUI that exits
// while hidden leaves paneMode2031 set, and the next theme flip pushes CSI 997
// into the shell that replaced it (#9993 via maybePushMode2031Flip). No reply is
// sent: a withdrawal is not a query.
const handleHiddenMode2031UnsubscribeFact = (): void => {
if (disposed || !isHiddenDeliveryGateManagedPty(transport.getPtyId())) {
return
}
deps.paneMode2031Ref.current.delete(pane.id)
deps.paneLastThemeModeRef.current.delete(pane.id)
}
deps.paneTransportsRef.current.set(pane.id, transport)
const terminalCapabilityRepliesDisposable = installTerminalCapabilityReplyHandlers({
terminal: pane.terminal,
@@ -5493,7 +5506,7 @@ export function connectPanePty(
let foregroundImmediateBudgetWindowStart = 0
let foregroundRewriteChunkEndedWithCarriageReturn = false
let foregroundRewriteCsiScanTail = ''
let hiddenMode2031ScanTail = ''
let mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE
const shouldSnapshotHiddenCodexOutput = shouldKeepHiddenStartupRendererQueriesLive(paneStartup)
let hiddenStartupRendererQueryPending = ''
let hiddenRendererStateDirty = false
@@ -5815,19 +5828,30 @@ export function connectPanePty(
}
}
function respondToSkippedMode2031Subscribe(data: string): void {
const scan = scanMode2031Sequences(hiddenMode2031ScanTail, data)
hiddenMode2031ScanTail = scan.tail
if (scan.finalState === 'unsubscribed') {
// Why here and not in xterm's CSI handler: xterm batches several PTY chunks into one
// synchronous parse, so a handler cannot tell where a chunk ended. fish enables and
// disables 2031 around every prompt, so answering a subscribe the same chunk withdraws
// pushes `?997;1n` into the prompt or a child's stdin as literal text (#9993). One raw
// chunk in, one order-aware decision out.
function observeLiveMode2031Chunk(data: string): void {
// Main's '2031-subscribe' fact is the sole responder for gate-managed PTYs; a second
// reply from here would answer one subscribe twice.
if (isHiddenDeliveryGateManagedPty(transport.getPtyId())) {
return
}
const result = scanMode2031ReplyDecision(mode2031ReplyScanState, data)
mode2031ReplyScanState = result.state
if (result.decision === 'unsubscribed') {
deps.paneMode2031Ref.current.delete(pane.id)
deps.paneLastThemeModeRef.current.delete(pane.id)
}
if (scan.finalState !== 'subscribed') {
if (result.decision !== 'subscribed') {
return
}
const settings = useAppStore.getState().settings
const mode = resolveTerminalColorSchemeMode(settings, getSystemPrefersDark())
// Why: hidden snapshot-backed panes skip xterm.write for PTY bytes; answer immediately so the reply can't outlive the program's read window.
// Why immediate: the reply must land inside the program's read window, and hidden
// snapshot-backed panes skip xterm.write for PTY bytes entirely.
deps.paneMode2031Ref.current.set(pane.id, true)
sendDesktopQueryReplyImmediate(mode2031SequenceFor(mode))
deps.paneLastThemeModeRef.current.set(pane.id, mode)
@@ -5905,9 +5929,6 @@ export function connectPanePty(
const synchronizedFrameLatencySensitive =
synchronizedForegroundOutput && synchronizedForegroundFrameInteractive
synchronizedForegroundOutputActive = nextSynchronizedForegroundOutputActive
if (!foreground && hiddenMode2031ScanTail) {
respondToSkippedMode2031Subscribe(data)
}
writeTerminalOutput(pane.terminal, data, {
foreground: foregroundOutput,
beforeWrite: beforeTerminalOutputWrite,
@@ -6077,7 +6098,6 @@ export function connectPanePty(
function skipHiddenRendererOutput(data: string): void {
writeHiddenStartupRendererQueries(data)
respondToSkippedMode2031Subscribe(data)
markHiddenOutputRestoreNeeded()
hiddenRendererStateDirty = true
if (hiddenOutputRestoreInFlight) {
@@ -6564,6 +6584,9 @@ export function connectPanePty(
function clearPaneMode2031State(): void {
deps.paneMode2031Ref.current.delete(pane.id)
deps.paneLastThemeModeRef.current.delete(pane.id)
// A partial CSI prefix belongs to the stream that produced it; carrying it into a
// replacement PTY would splice two unrelated byte ranges into one sequence.
mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE
}
function pulseVisibleLocalPtySizeForTuiRepaint(ptyId: string): void {
@@ -6586,7 +6609,6 @@ export function connectPanePty(
function skipBackgroundAlternateScreenOutput(data: string): void {
writeHiddenStartupRendererQueries(data)
respondToSkippedMode2031Subscribe(data)
resetSkippedHiddenRendererRiskState()
hiddenRendererStateDirty = true
recordHiddenRendererSkip(data.length)
@@ -7063,6 +7085,7 @@ export function connectPanePty(
}
observeStartupDraftPasteReadiness(data)
resetHiddenOutputRestoreIfPtyChanged()
observeLiveMode2031Chunk(data)
if (meta?.droppedOutput === true) {
// Why gated (rc.7.perf loop): a visible pane's cap-drop during its own restore is self-caused backpressure; defer to one post-flood repaint instead of re-arming per sentinel.
if (meta?.background !== true && isForegroundRestoreBackpressureContext()) {
@@ -7094,9 +7117,6 @@ export function connectPanePty(
if (!foreground) {
syncHiddenRendererPtyDelivery()
}
if (foreground && hiddenMode2031ScanTail) {
respondToSkippedMode2031Subscribe(data)
}
// Post-restore reconciliation: drop chunks the snapshot covers, force a fresh restore for unmappable seq gaps; runs after byte observers, before any xterm write.
const reconciliation = reconcileChunkAgainstRestoredSnapshot(data, meta)
if (reconciliation.action === 'drop-duplicate') {
@@ -8261,9 +8281,6 @@ export function connectPanePty(
// Why: the hidden-delivery gate must follow every pane visibility flip.
syncHiddenRendererPtyDelivery()
},
isHiddenDeliveryGateManagedPty() {
return isHiddenDeliveryGateManagedPty(transport.getPtyId())
},
// Why: visible-resume size readback repairs dropped hidden resizes without refitting against xterm's transient hidden DOM fallback.
noteVisibilityResume() {
ptySizeReassertion.request({ fit: false })
@@ -1,16 +1,13 @@
import { describe, expect, it, vi } from 'vitest'
import { Terminal } from '@xterm/headless'
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
import { getDefaultSettings } from '../../../../shared/constants'
import {
applyTerminalAppearance,
hexToRgba,
installMode2031Handlers,
publishTerminalViewAttributesAtAppStart
} from './terminal-appearance'
import { maybePushMode2031Flip } from './terminal-mode-2031-replies'
import { mode2031SequenceFor } from '../../../../shared/terminal-color-scheme-protocol'
import { replayIntoTerminal, type ReplayingPanesRef } from './replay-guard'
import { _resetTerminalViewAttributesPublisherForTest } from './terminal-view-attributes-publisher'
import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes'
@@ -138,219 +135,6 @@ describe('maybePushMode2031Flip', () => {
expect(last.get(2)).toBe('dark')
})
})
describe('installMode2031Handlers', () => {
// Regression coverage for the "random characters on restart" bug: a replayed `CSI ?2031h` pushed `CSI ?997;1n` into the fresh shell.
function writeSync(term: Terminal, data: string): Promise<void> {
return new Promise((resolve) => term.write(data, resolve))
}
function makeReplayingRef(): ReplayingPanesRef {
return { current: new Map() } as ReplayingPanesRef
}
function setup(paneId = 1): {
term: Terminal
pane: ManagedPane
replayingPanesRef: ReplayingPanesRef
onSubscribe: ReturnType<typeof vi.fn>
paneMode2031: Map<number, boolean>
paneLastThemeMode: Map<number, 'dark' | 'light'>
dispose: () => void
} {
const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true })
const pane = { id: paneId, terminal: term } as unknown as ManagedPane
const replayingPanesRef = makeReplayingRef()
const paneMode2031 = new Map<number, boolean>()
const paneLastThemeMode = new Map<number, 'dark' | 'light'>()
const onSubscribe = vi.fn()
const disposables = installMode2031Handlers({
paneId,
parser: term.parser,
onSubscribe,
isReplaying: () => (replayingPanesRef.current.get(paneId) ?? 0) > 0,
paneMode2031,
paneLastThemeMode
})
return {
term,
pane,
replayingPanesRef,
onSubscribe,
paneMode2031,
paneLastThemeMode,
dispose: () => {
for (const d of disposables) {
d.dispose()
}
term.dispose()
}
}
}
it('records subscribe and fires onSubscribe on a live `CSI ?2031h`', async () => {
const h = setup()
try {
await writeSync(h.term, '\x1b[?2031h')
expect(h.paneMode2031.get(1)).toBe(true)
expect(h.onSubscribe).toHaveBeenCalledTimes(1)
} finally {
h.dispose()
}
})
it('does NOT fire onSubscribe or record state when the sequence arrives during replay', async () => {
// On cold restore the replay guard is set before xterm parses, so the handler must skip both the push and the bookkeeping.
const h = setup()
try {
replayIntoTerminal(h.pane, h.replayingPanesRef, '\x1b[?2031h')
// write() is async: the replay guard stays engaged until the write-completion callback fires.
await new Promise<void>((resolve) => h.term.write('', resolve))
expect(h.onSubscribe).not.toHaveBeenCalled()
expect(h.paneMode2031.has(1)).toBe(false)
expect(h.paneLastThemeMode.has(1)).toBe(false)
// Once the replay window closes, the pane is not marked replaying.
expect(h.replayingPanesRef.current.get(1) ?? 0).toBe(0)
} finally {
h.dispose()
}
})
it('still honors a real `CSI ?2031h` received after a replay window closes', async () => {
// A real `?2031h` from a TUI relaunched after cold restore must take effect normally.
const h = setup()
try {
replayIntoTerminal(h.pane, h.replayingPanesRef, '\x1b[?2031h')
await new Promise<void>((resolve) => h.term.write('', resolve))
expect(h.onSubscribe).not.toHaveBeenCalled()
await writeSync(h.term, '\x1b[?2031h')
expect(h.paneMode2031.get(1)).toBe(true)
expect(h.onSubscribe).toHaveBeenCalledTimes(1)
} finally {
h.dispose()
}
})
it('clears subscribe state on `CSI ?2031l` regardless of replay state', async () => {
// The `l` (unsubscribe) branch is intentionally not replay-guarded: clearing is harmless since we only send on subscribe.
const h = setup()
try {
// Non-replay path: subscribe then unsubscribe clears state.
await writeSync(h.term, '\x1b[?2031h')
h.paneLastThemeMode.set(1, 'dark')
expect(h.paneMode2031.get(1)).toBe(true)
await writeSync(h.term, '\x1b[?2031l')
expect(h.paneMode2031.has(1)).toBe(false)
expect(h.paneLastThemeMode.has(1)).toBe(false)
// Replay path: the `l` handler must still clear even during a replay window.
await writeSync(h.term, '\x1b[?2031h')
h.paneLastThemeMode.set(1, 'dark')
expect(h.paneMode2031.get(1)).toBe(true)
replayIntoTerminal(h.pane, h.replayingPanesRef, '\x1b[?2031l')
await new Promise<void>((resolve) => h.term.write('', resolve))
expect(h.paneMode2031.has(1)).toBe(false)
expect(h.paneLastThemeMode.has(1)).toBe(false)
} finally {
h.dispose()
}
})
it('returns `false` so compound DEC private modes still reach xterm', async () => {
// Why: handlers return `false` so compound sequences like `CSI ?25;2031h` still reach xterm's built-in DEC private mode handler.
const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true })
const paneMode2031 = new Map<number, boolean>()
const paneLastThemeMode = new Map<number, 'dark' | 'light'>()
const onSubscribe = vi.fn()
const returnValues: boolean[] = []
// Cast: parser cb returns plain `boolean` but `Mode2031Parser` reflects xterm's `boolean | Promise<boolean>` (handlers here are sync).
const spyParser: Parameters<typeof installMode2031Handlers>[0]['parser'] = {
registerCsiHandler: (id, cb) =>
term.parser.registerCsiHandler(id, (params) => {
const r = cb(params) as boolean
returnValues.push(r)
return r
})
}
const disposables = installMode2031Handlers({
paneId: 1,
parser: spyParser,
onSubscribe,
isReplaying: () => false,
paneMode2031,
paneLastThemeMode
})
try {
// Compound: ?25 (cursor show) + ?2031 (color-scheme subscribe).
await writeSync(term, '\x1b[?25;2031h')
// Our 2031 recording fired:
expect(paneMode2031.get(1)).toBe(true)
expect(onSubscribe).toHaveBeenCalledTimes(1)
// Every handler invocation returned `false`, so xterm's built-in DEC private mode handler still processes the sequence.
expect(returnValues.length).toBeGreaterThan(0)
expect(returnValues.every((v) => v === false)).toBe(true)
} finally {
for (const d of disposables) {
d.dispose()
}
term.dispose()
}
})
it('keeps per-pane state isolated when two panes share the parser API', async () => {
// The subscribe bookkeeping map is shared across panes, so a replay on pane 1 must not leak into pane 2's live subscribe.
const shared2031 = new Map<number, boolean>()
const sharedLast = new Map<number, 'dark' | 'light'>()
const replayingPanesRef = makeReplayingRef()
const term1 = new Terminal({ cols: 80, rows: 24, allowProposedApi: true })
const term2 = new Terminal({ cols: 80, rows: 24, allowProposedApi: true })
const pane1 = { id: 1, terminal: term1 } as unknown as ManagedPane
const onSub1 = vi.fn()
const onSub2 = vi.fn()
const d1 = installMode2031Handlers({
paneId: 1,
parser: term1.parser,
onSubscribe: onSub1,
isReplaying: () => (replayingPanesRef.current.get(1) ?? 0) > 0,
paneMode2031: shared2031,
paneLastThemeMode: sharedLast
})
const d2 = installMode2031Handlers({
paneId: 2,
parser: term2.parser,
onSubscribe: onSub2,
isReplaying: () => (replayingPanesRef.current.get(2) ?? 0) > 0,
paneMode2031: shared2031,
paneLastThemeMode: sharedLast
})
try {
// Replay on pane 1 must not subscribe.
replayIntoTerminal(pane1, replayingPanesRef, '\x1b[?2031h')
await new Promise<void>((resolve) => term1.write('', resolve))
expect(onSub1).not.toHaveBeenCalled()
expect(shared2031.has(1)).toBe(false)
// Live on pane 2 must subscribe normally.
await writeSync(term2, '\x1b[?2031h')
expect(onSub2).toHaveBeenCalledTimes(1)
expect(shared2031.get(2)).toBe(true)
} finally {
for (const d of [...d1, ...d2]) {
d.dispose()
}
term1.dispose()
term2.dispose()
}
})
})
describe('applyTerminalAppearance theme assignment', () => {
// xterm rebuilds the palette on any new theme-object identity (wiping OSC color mutations), so the assignment must be value-gated.
function makePane(id: number): ManagedPane {

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