diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml index 7695d2bec9b..95ef7e96cc9 100644 --- a/.github/actions/install-node-dependencies/action.yml +++ b/.github/actions/install-node-dependencies/action.yml @@ -10,6 +10,10 @@ inputs: description: Node.js version override; defaults to the version declared in package.json. required: false default: '' + cache-dependency-path: + description: Lockfiles for the pnpm download store; include mobile/pnpm-lock.yaml only when the job installs mobile dependencies. + required: false + default: pnpm-lock.yaml persist-native-cache: description: Save restored native modules at job end. Set false when a later step overwrites the same path with a different ABI. required: false @@ -39,9 +43,7 @@ runs: with: install: false - # Why both lockfiles: setup-node keys the pnpm store on the root lockfile alone, so - # jobs that also install mobile restored a store with none of the React Native tree - # in it and re-downloaded the lot on every run. + # Desktop-only jobs should not miss their download cache when mobile dependencies change. - name: Setup Node.js id: default-node if: inputs.node-version == '' @@ -49,9 +51,7 @@ runs: with: node-version-file: package.json cache: pnpm - cache-dependency-path: | - pnpm-lock.yaml - mobile/pnpm-lock.yaml + cache-dependency-path: ${{ inputs.cache-dependency-path }} - name: Setup requested Node.js id: requested-node @@ -60,9 +60,7 @@ runs: with: node-version: ${{ inputs.node-version }} cache: pnpm - cache-dependency-path: | - pnpm-lock.yaml - mobile/pnpm-lock.yaml + cache-dependency-path: ${{ inputs.cache-dependency-path }} - name: Validate native runtime shell: bash diff --git a/.github/workflows/adhoc-mac-build.yml b/.github/workflows/adhoc-mac-build.yml index 3e17eee9b68..ac49acdbb83 100644 --- a/.github/workflows/adhoc-mac-build.yml +++ b/.github/workflows/adhoc-mac-build.yml @@ -160,16 +160,14 @@ jobs: - name: Checkout the requested ref uses: actions/checkout@v6 - env: - # Full-history checkout must also preserve case-twin branch and tag names. - GIT_DEFAULT_REF_FORMAT: reftable with: # Why an input at all rather than just github.ref: the whole point is to # build code that has not landed, and the workflow definition itself # always comes from the dispatch ref — naming the branch here instead # applies main's current copy of this file to an arbitrary branch. ref: ${{ steps.vetted.outputs.sha }} - fetch-depth: 0 + # Version helpers only read HEAD; published versions come from the release API. + fetch-depth: 1 # This job only reads stablyai/orca and never pushes; every write goes # to the adhoc repo through a minted App token passed by env. Not # persisting the checkout credential shrinks the blast radius if a build diff --git a/.github/workflows/cloud-deploy-relay-production-same-cap.yml b/.github/workflows/cloud-deploy-relay-production-same-cap.yml index 50d609b30d3..31fe9bfb203 100644 --- a/.github/workflows/cloud-deploy-relay-production-same-cap.yml +++ b/.github/workflows/cloud-deploy-relay-production-same-cap.yml @@ -62,7 +62,7 @@ on: required: false type: string canary-run-id: - description: Successful same-commit canary run required for batch-apply + description: Successful same-code canary in this rehome control generation; reusable across batches required: false type: string confirmation: diff --git a/.github/workflows/daily-mac-build.yml b/.github/workflows/daily-mac-build.yml index 45130e932d8..e70fc928295 100644 --- a/.github/workflows/daily-mac-build.yml +++ b/.github/workflows/daily-mac-build.yml @@ -90,7 +90,8 @@ jobs: uses: actions/checkout@v6 with: ref: main - fetch-depth: 0 + # Version helpers only read HEAD; published versions come from the release API. + fetch-depth: 1 # Why: this job only reads stablyai/orca and never pushes; every write # goes to the daily repo through a minted App token passed by env. # Not persisting the checkout credential shrinks the blast radius if a diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index a07bf0991ec..f42d1184ce9 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -170,8 +170,34 @@ jobs: # artifact instead of starting five concurrent electron-vite builds. # ORCA_E2E_FORWARD_APP_LOGS keeps startup failures visible when Electron # launches but never creates a BrowserWindow. + - name: Balance E2E shard from timing evidence + env: + ORCA_BACKGROUND_LAUNCH: '1' + SKIP_BUILD: '1' + ORCA_E2E_FORWARD_APP_LOGS: '1' + ORCA_E2E_WEB_CLIENT: '1' + ORCA_RELAY_PATH: ${{ github.workspace }}/out/relay + run: | + mkdir -p ci-shards + pnpm exec playwright test --config tests/playwright.config.ts --project=electron-headless --list --reporter=json > ci-shards/discovery.json + export ORCA_SHARD_SOURCE_SHA="$(git rev-parse HEAD)" + node config/scripts/ci-e2e-shard-plan.mjs ci-shards/discovery.json '${{ matrix.shard }}' ci-shards + pnpm exec playwright test --config tests/playwright.config.ts --project=electron-headless --test-list=ci-shards/selected.txt --list --reporter=json > ci-shards/selected-discovery.json + node config/scripts/ci-e2e-shard-plan.mjs --verify ci-shards/assignment.json ci-shards/selected-discovery.json + - name: Run E2E tests (${{ matrix.shard_name }}) - run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --shard=${{ matrix.shard }} + run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --test-list=ci-shards/selected.txt + + - name: Upload E2E shard assignment + if: always() + # Diagnostic upload outages must not change the test verdict. + continue-on-error: true + uses: actions/upload-artifact@v7 + with: + name: e2e-shard-${{ matrix.shard_name }}-attempt-${{ github.run_attempt }} + path: ci-shards/ + retention-days: 14 + if-no-files-found: warn # The frame benchmark needs a mapped window, which the headless shards exclude. - name: Run worktree first-paint benchmark diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml index c300b2543b8..e55adc54995 100644 --- a/.github/workflows/hourly-mac-build.yml +++ b/.github/workflows/hourly-mac-build.yml @@ -137,7 +137,8 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.head_sha }} - fetch-depth: 0 + # Version helpers only read HEAD; published versions come from the release API. + fetch-depth: 1 # Why: this job only reads stablyai/orca and never pushes; every write # goes to the hourly repo through a minted App token passed by env. # Not persisting the checkout credential shrinks the blast radius if a diff --git a/.github/workflows/mobile-ios-release.yml b/.github/workflows/mobile-ios-release.yml index 934b3f694a3..dd9a265d0c8 100644 --- a/.github/workflows/mobile-ios-release.yml +++ b/.github/workflows/mobile-ios-release.yml @@ -94,6 +94,8 @@ jobs: run: node -e 'const fs = require("node:fs"); const { expo } = require("./app.json"); fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${expo.version}\nbuild_number=${expo.ios.buildNumber}\n`)' - name: Expo prebuild + env: + ORCA_IOS_APS_ENVIRONMENT: production run: npx expo prebuild --platform ios --no-install - name: Install CocoaPods diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index 6dbfc02aa3c..9afc865f87e 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -41,6 +41,10 @@ jobs: uses: actions/checkout@v6 - uses: ./.github/actions/install-node-dependencies + with: + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml # bundler-cache installs mobile/Gemfile.lock, so this job is also what # proves the pinned fastlane the release workflow depends on still diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c49091ab148..45a3d12c2d8 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -126,6 +126,9 @@ jobs: - uses: ./.github/actions/install-node-dependencies with: native-runtime: node + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml - name: Lint run: pnpm exec oxlint --format github @@ -775,18 +778,9 @@ jobs: [[ "$rpm_marker" == rpm ]] || { echo "Expected rpm marker, got: $rpm_marker"; exit 1; } - name: Verify headless serve signal shutdown - run: node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage - - - name: Verify extracted launcher serve signal shutdown run: >- node config/scripts/run-headless-serve-shutdown-docker.mjs - --appimage dist/orca-linux.AppImage --entrypoint launcher - - - name: Verify AppImage CLI registration and serve signal shutdown - run: >- - node config/scripts/run-headless-serve-shutdown-docker.mjs - --appimage dist/orca-linux.AppImage --entrypoint appimage - --signal-target serving-electron --int-delivery pid + --appimage dist/orca-linux.AppImage --all-entrypoints # A default container reproduces the hostile AppImage launch environment. - name: Verify Linux CLI launch contract diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 0c215db94d3..b21feae3230 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -45,7 +45,11 @@ jobs: npx --yes pnpm@10.24.0 --filter '@orca-cloud/relay^...' build - name: Test shard + env: + ORCA_BALANCE_UNIT_SHARDS: '1' + ORCA_BACKGROUND_LAUNCH: '1' run: | + export ORCA_SHARD_SOURCE_SHA="$(git rev-parse HEAD)" pnpm exec vitest run --config config/vitest.config.ts \ --exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \ --exclude=src/main/daemon/shell-ready.test.ts \ @@ -65,3 +69,14 @@ jobs: --exclude=src/shared/posix-command-path-lookup.test.ts \ --exclude=tests/e2e/cross-version-wire/** \ --shard=${{ matrix.shard }}/${{ matrix.shard_total }} + + - name: Upload unit shard assignment + if: always() + # Diagnostic upload outages must not change the test verdict. + continue-on-error: true + uses: actions/upload-artifact@v7 + with: + name: unit-shard-node-${{ matrix.node }}-${{ matrix.shard }}-attempt-${{ github.run_attempt }} + path: ci-shards/ + retention-days: 14 + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index e5207a25015..cf2f7244eb3 100644 --- a/.gitignore +++ b/.gitignore @@ -104,6 +104,7 @@ docs/** !docs/mobile-terminal-shortcut-bar.md !docs/reference/ !docs/reference/agent-pty-transcript-capture.md +!docs/reference/agent-session-search-query-tuning.md !docs/reference/agent-status-store.md !docs/reference/antigravity-readiness-evidence.md !docs/reference/git-compatibility.md diff --git a/cloud/apps/relay-ops/src/incident-monitor.test.ts b/cloud/apps/relay-ops/src/incident-monitor.test.ts index c1a073cde4a..be35b55f65b 100644 --- a/cloud/apps/relay-ops/src/incident-monitor.test.ts +++ b/cloud/apps/relay-ops/src/incident-monitor.test.ts @@ -273,6 +273,26 @@ describe('incident monitor evaluator', () => { ) }) + it('allows at most three unexpected director errors per five minutes without relaxing other gates', () => { + for (const errors of [1, 2, 3]) { + const sample = healthySample() + sample.sources['cloud-monitoring']!.signals['director.errors'] = signal(errors) + expect(evaluateIncidentSample(sample, startedAt).status).toBe('green') + } + const excess = healthySample() + excess.sources['cloud-monitoring']!.signals['director.errors'] = signal(4) + expect(evaluateIncidentSample(excess, startedAt).failures).toContainEqual( + expect.objectContaining({ signal: 'director.errors', observed: 4, threshold: 3 }) + ) + const auth = healthySample() + auth.sources['cloud-monitoring']!.signals['auth.errors'] = signal(1) + expect(evaluateIncidentSample(auth, startedAt).status).toBe('freeze') + const pressure = healthySample() + pressure.sources['cloud-monitoring']!.signals['director.errors'] = signal(1) + pressure.sources['cloud-monitoring']!.signals['cloud_sql.cpu'] = signal(0.81) + expect(evaluateIncidentSample(pressure, startedAt).status).toBe('freeze') + }) + it('freezes on SQL, director, relay pool, heartbeat, and migration breaches', () => { const sample = healthySample() sample.sources['cloud-monitoring']!.signals['cloud_sql.cpu'] = signal(0.81) diff --git a/cloud/apps/relay-ops/src/incident-monitor.ts b/cloud/apps/relay-ops/src/incident-monitor.ts index 0887bb2d1ee..a48e100a6d8 100644 --- a/cloud/apps/relay-ops/src/incident-monitor.ts +++ b/cloud/apps/relay-ops/src/incident-monitor.ts @@ -101,7 +101,8 @@ export const INCIDENT_MONITOR_THRESHOLDS = { directorCpuUtilization: 0.8, directorMemoryUtilization: 0.8, directorConcurrency: 64, - directorErrors: 0, + // Sparse connection timeouts must not block a healthy rollout; four/5min still freezes. + directorErrors: 3, authErrors: 0, // Why: 800 exceeded the 600 hard cap, so this could never trigger on a capped cell. 500 is // the ordinary admission limit a cell actually stops at (600 cap - 100 control-rebind reserve). diff --git a/cloud/dev/scripts/probe-relay-rehome-trust.mjs b/cloud/dev/scripts/probe-relay-rehome-trust.mjs index 2208131e58a..73ea44a40f7 100644 --- a/cloud/dev/scripts/probe-relay-rehome-trust.mjs +++ b/cloud/dev/scripts/probe-relay-rehome-trust.mjs @@ -38,7 +38,7 @@ export function parseRehomeTrustProbeArguments(argv, environment = process.env) export async function probeRehomeTrust(config, dependencies = {}) { const fetchImpl = dependencies.fetch ?? fetch - const response = await fetchAdminOnceMore( + const request = () => fetchAdminOnceMore( fetchImpl, `${config.directorOrigin}/v1/admin/regional-rehome-trust-probe`, { @@ -55,9 +55,26 @@ export async function probeRehomeTrust(config, dependencies = {}) { }, { wait: dependencies.wait } ) - const body = await response.json().catch(() => ({})) + let response = await request() + let body = await response.json().catch(() => ({})) + // The director wraps source HTTP failures in 409; retry only explicit transient statuses. + if (response.status === 409 && /^regional_rehome_trust_probe_source_(500|502|503|504)$/.test(body?.error ?? '')) { + await (dependencies.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))))(2_000) + response = await request() + body = await response.json().catch(() => ({})) + } if (!response.ok) { - throw new Error(`application-mediated rehome trust probe returned ${response.status}`) + const safeReasons = new Set([ + 'invalid_token', 'director_only', 'invalid_request', + 'regional_rehome_trust_not_configured', + 'regional_rehome_trust_probe_source_unavailable', + 'regional_rehome_trust_probe_source_invalid_response', + 'regional_rehome_trust_probe_not_proven', + ...[400, 401, 403, 404, 409, 429, 500, 502, 503, 504] + .map((status) => `regional_rehome_trust_probe_source_${status}`) + ]) + const reason = safeReasons.has(body?.error) ? body.error : 'unrecognized_error' + throw new Error(`application-mediated rehome trust probe returned ${response.status}: ${reason}`) } if ( body.v !== 1 || diff --git a/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs b/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs index 789d33c40b6..034fcd06c4f 100644 --- a/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs +++ b/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs @@ -131,3 +131,40 @@ test('approves the asia-east2 rehome sources and still rejects unlisted cells', ) } }) + +test('retries one director-wrapped source 503 without relaxing the proof', async () => { + let calls = 0 + const result = await probeRehomeTrust(parseRehomeTrustProbeArguments(argv, environment), { + wait: async () => {}, + fetch: async () => ++calls === 1 + ? Response.json({ error: 'regional_rehome_trust_probe_source_503' }, { status: 409 }) + : Response.json(provenProbe) + }) + assert.equal(calls, 2) + assert.equal(result.proven, true) +}) + +test('reports safe trust reasons, keeps rejection final, and redacts arbitrary error text', async () => { + for (const reason of ['regional_rehome_trust_probe_source_403', 'secret-token-example']) { + let calls = 0 + await assert.rejects(probeRehomeTrust(parseRehomeTrustProbeArguments(argv, environment), { + wait: async () => { throw new Error('must not retry') }, + fetch: async () => { calls++; return Response.json({ error: reason }, { status: 409 }) } + }), error => { + assert.match(error.message, /returned 409/) + assert.ok(!error.message.includes('secret-token-example')) + if (reason.endsWith('_403')) assert.match(error.message, /source_403/) + return true + }) + assert.equal(calls, 1) + } +}) + +test('stops after the second wrapped transient failure', async () => { + let calls = 0 + await assert.rejects(probeRehomeTrust(parseRehomeTrustProbeArguments(argv, environment), { + wait: async () => {}, + fetch: async () => { calls++; return Response.json({ error: 'regional_rehome_trust_probe_source_503' }, { status: 409 }) } + }), /returned 409.*source_503/) + assert.equal(calls, 2) +}) diff --git a/cloud/dev/scripts/relay-production-same-cap-wave.mjs b/cloud/dev/scripts/relay-production-same-cap-wave.mjs index 6e84c1c9104..8caa68e7ff2 100644 --- a/cloud/dev/scripts/relay-production-same-cap-wave.mjs +++ b/cloud/dev/scripts/relay-production-same-cap-wave.mjs @@ -87,18 +87,21 @@ export function canaryAuthority(input) { } export function verifyCanaryAuthority(authority, expected, repositoryRoot) { + const selectorGeneration = Number(expected.selectorGeneration) if ( authority?.v !== 1 || !/^[0-9a-f]{40}$/.test(authority.commitSha ?? '') || authority.runId !== expected.runId || authority.targetDigest !== expected.targetDigest || authority.rollbackDigest !== expected.rollbackDigest || - authority.selectorGeneration !== Number(expected.selectorGeneration) || + !Number.isSafeInteger(authority.selectorGeneration) || + authority.selectorGeneration < 0 || + !Number.isSafeInteger(selectorGeneration) || + selectorGeneration < authority.selectorGeneration || authority.rehomeGeneration !== Number(expected.rehomeGeneration) || !SAME_CAP_CELLS.includes(authority.cellId) ) throw new Error('canary authority does not match this batch') - // The batch dispatch resolves main after the canary sealed, so bind to the same code, not the - // same SHA; every field above still pins this batch to that exact canary. + // Each cell checks exact live selector state; later batches may reuse this control epoch's canary. requireSameEvidenceCode({ sealedSha: authority.commitSha, currentSha: expected.commitSha, diff --git a/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs b/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs index d636c324b33..7377f7a7af3 100644 --- a/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs +++ b/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs @@ -109,6 +109,41 @@ test('seals and verifies canary authority for later batches', () => { }), /does not match/) }) +test('reuses a canary across selector advances only within the same control epoch', () => { + const authority = canaryAuthority({ + cellIds: 'production-gce-c7', targetDigest, rollbackDigest, + confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} production-gce-c7`, + commitSha: 'c'.repeat(40), runId: '42', selectorGeneration: '11', rehomeGeneration: '4' + }) + const expected = { + commitSha: 'c'.repeat(40), runId: '42', targetDigest, rollbackDigest, + selectorGeneration: '21', rehomeGeneration: '4' + } + for (const generation of ['13', '14', '21', '29']) { + assert.equal(verifyCanaryAuthority(authority, { + ...expected, selectorGeneration: generation + }), authority) + } + for (const generation of ['12', '-1', 'NaN', 'Infinity', '13.5', '9007199254740992']) { + assert.throws(() => verifyCanaryAuthority(authority, { + ...expected, selectorGeneration: generation + }), /does not match/) + } + for (const generation of [-1, NaN, Infinity, 13.5, '13', Number.MAX_SAFE_INTEGER + 1]) { + assert.throws(() => verifyCanaryAuthority({ + ...authority, selectorGeneration: generation + }, expected), /does not match/) + } + for (const mismatch of [ + { rehomeGeneration: '3' }, { rehomeGeneration: '5' }, + { targetDigest: rollbackDigest }, { rollbackDigest: targetDigest }, { runId: '43' } + ]) { + assert.throws(() => verifyCanaryAuthority(authority, { + ...expected, ...mismatch + }), /does not match/) + } +}) + function gitIn(root, ...args) { return execFileSync('git', ['-C', root, ...args], { encoding: 'utf8' }).trim() } @@ -158,7 +193,7 @@ test('a batch trusts a canary sealed by identical code at an ancestor commit', a runId: '42', targetDigest, rollbackDigest, - selectorGeneration: '13', + selectorGeneration: '21', rehomeGeneration: '4' }, repositoryRoot) assert.equal(verifyAt(repository.sameCode, repository.root).cellId, 'production-gce-c7') diff --git a/cloud/docs/relay-incident-monitor.md b/cloud/docs/relay-incident-monitor.md index 696efb85296..a97e92949e8 100644 --- a/cloud/docs/relay-incident-monitor.md +++ b/cloud/docs/relay-incident-monitor.md @@ -112,7 +112,8 @@ durably marked consumed before mutation and cannot authorize another run. | Director instances | outside 5–6 | | Director CPU or memory | over 80% | | Director concurrency | over 64 | -| Unexpected director 5xx or auth 5xx in five minutes | over 0 | +| Unexpected director 5xx in five minutes (excludes 503) | over 3 | +| Auth 5xx in five minutes | over 0 | | Connections per cell process | over 500 | | Queued bytes per cell process | over 48 MiB | | Blocked or expired/unregistered migration | over 0 | @@ -276,3 +277,7 @@ without its segment is a compile error in relay-contract, not a silent gap. load the director's three-connection database pool. - Added private atomic state, idempotent JSONL checkpoints, and secret-safe Markdown evidence. - Added the manual production workflow. It has not been dispatched. + +### Director error allowance (2026-09-12) + +The serving-cell rollout observed three unexpected director 500 responses among approximately 33,600 responses in an hour, all two-second PostgreSQL connection timeouts. CPU remained near 30–37% and the zero-error bar repeatedly prevented any cell mutation. The five-minute allowance is now three non-503 director 5xx; four freezes. Auth errors, data freshness, active probes, SQL/pool pressure and other limits are unchanged. This is a bounded operational allowance, not a calibrated SLO or proof that intermittent failures are resolved; persistent low-frequency errors below this limit still require diagnosis. diff --git a/config/scripts/ci-dependency-download-cache.test.mjs b/config/scripts/ci-dependency-download-cache.test.mjs new file mode 100644 index 00000000000..9cce5bb0f6f --- /dev/null +++ b/config/scripts/ci-dependency-download-cache.test.mjs @@ -0,0 +1,30 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' + +const read = (path) => parse(readFileSync(path, 'utf8')) +const workflow = (name) => read(`.github/workflows/${name}.yml`) +const action = read('.github/actions/install-node-dependencies/action.yml') + +describe('CI dependency download caches', () => { + it('scopes desktop stores to the root lockfile and lets mixed installs opt in', () => { + expect(action.inputs['cache-dependency-path'].default).toBe('pnpm-lock.yaml') + for (const step of action.runs.steps.filter((step) => step.uses === 'actions/setup-node@v6')) { + expect(step.with.cache).toBe('pnpm') + expect(step.with['cache-dependency-path']).toBe('${{ inputs.cache-dependency-path }}') + } + const install = action.runs.steps.find((step) => step.name === 'Install dependencies') + expect(install.if).toBeUndefined() + expect(install.run).toContain('pnpm install --frozen-lockfile --ignore-scripts') + expect(install.run).toContain( + 'diff --exit-code -- package.json pnpm-lock.yaml pnpm-workspace.yaml' + ) + const mobile = workflow('mobile').jobs.verify.steps.find((step) => + step.uses?.includes('install-node-dependencies') + ) + expect(mobile.with['cache-dependency-path'].trim().split('\n')).toEqual([ + 'pnpm-lock.yaml', + 'mobile/pnpm-lock.yaml' + ]) + }) +}) diff --git a/config/scripts/ci-e2e-shard-plan.mjs b/config/scripts/ci-e2e-shard-plan.mjs new file mode 100644 index 00000000000..ea6002451a3 --- /dev/null +++ b/config/scripts/ci-e2e-shard-plan.mjs @@ -0,0 +1,97 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { + balanceFiles, + compareIds, + readTimingBaseline, + writeAssignment +} from './ci-shard-assignment.mjs' + +export function discoverE2eFiles(report) { + if (report.errors?.length) { + throw new Error('Playwright discovery reported errors') + } + const files = new Map() + function visit(suite) { + for (const spec of suite.specs ?? []) { + const file = spec.file.replaceAll('\\', '/') + if (file.startsWith('/') || file.split('/').includes('..') || /[\n\r>›]/.test(file)) { + throw new Error(`Unsafe test-list path: ${file}`) + } + for (const test of spec.tests) { + const id = `${test.projectName}:${spec.id}` + const ids = files.get(file) ?? [] + ids.push(id) + files.set(file, ids) + } + } + for (const child of suite.suites ?? []) { + visit(child) + } + } + for (const suite of report.suites) { + visit(suite) + } + if (!files.size) { + throw new Error('Playwright discovered no tests') + } + const ids = [...files.values()].flat() + if (new Set(ids).size !== ids.length) { + throw new Error('Duplicate discovered test identity') + } + return Object.fromEntries([...files.entries()].sort(([a], [b]) => compareIds(a, b))) +} + +export function planE2e(report, count, baseline) { + const testsByFile = discoverE2eFiles(report) + const timings = Object.fromEntries( + Object.entries(baseline.timings).map(([file, duration]) => [ + file.replace(/^tests\/e2e\//, ''), + duration + ]) + ) + const assignment = balanceFiles(Object.keys(testsByFile), count, timings) + return { ...assignment, testsByFile, baselineSha256: baseline.baselineSha256 } +} + +export function verifyE2eSelection(assignment, report) { + const actual = Object.values(discoverE2eFiles(report)).flat().sort(compareIds) + const expected = assignment.shards[assignment.selectedShard - 1].files + .flatMap((file) => assignment.testsByFile[file]) + .sort(compareIds) + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error('Native Playwright selection differs from shard assignment') + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + if (process.argv[2] === '--verify') { + verifyE2eSelection( + JSON.parse(readFileSync(process.argv[3], 'utf8')), + JSON.parse(readFileSync(process.argv[4], 'utf8')) + ) + } else { + const [input, shard, directory] = process.argv.slice(2) + const match = shard?.match(/^(\d+)\/(\d+)$/) + if (!input || !directory || !match) { + throw new Error('Usage: ci-e2e-shard-plan.mjs DISCOVERY INDEX/COUNT OUTPUT_DIRECTORY') + } + const index = Number(match[1]) + const count = Number(match[2]) + if (index < 1 || index > count) { + throw new Error('Invalid shard index') + } + const assignment = planE2e( + JSON.parse(readFileSync(input, 'utf8')), + count, + readTimingBaseline('e2e') + ) + const selected = assignment.shards[index - 1].files + if (!selected.length) { + throw new Error('Empty E2E shard') + } + writeAssignment(join(directory, 'assignment.json'), { ...assignment, selectedShard: index }) + writeFileSync(join(directory, 'selected.txt'), `${selected.join('\n')}\n`) + } +} diff --git a/config/scripts/ci-e2e-shard-selection.test.mjs b/config/scripts/ci-e2e-shard-selection.test.mjs new file mode 100644 index 00000000000..15a031179e7 --- /dev/null +++ b/config/scripts/ci-e2e-shard-selection.test.mjs @@ -0,0 +1,73 @@ +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { expect, it } from 'vitest' +import { runProcess } from '../../src/shared/child-process/run-process' +import { planE2e, verifyE2eSelection } from './ci-e2e-shard-plan.mjs' + +const require = createRequire(import.meta.url) + +it('native Playwright test-list preserves full discovery, serial suites, skips and headful filtering', async () => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), 'orca-playwright-shards-'))) + const testPackage = JSON.stringify(require.resolve('@stablyai/playwright-test')) + const config = join(directory, 'playwright.config.cjs') + writeFileSync( + config, + `module.exports = { testDir: '.', fullyParallel: true, projects: [{ name: 'electron-headless', grepInvert: /@headful/ }] }` + ) + for (let index = 0; index < 17; index++) { + writeFileSync( + join(directory, `file-${index}.spec.cjs`), + ` + const { test } = require(${testPackage}); + test('normal', () => {}); + test.skip('skipped', () => {}); + test('visible @headful', () => {}); + test.describe.serial('serial', () => { + test('first', () => {}); + test('second', () => {}); + }); + ` + ) + } + async function discover(extra = []) { + const result = await runProcess({ + program: process.execPath, + cwd: directory, + args: [ + join(dirname(require.resolve('playwright/package.json')), 'cli.js'), + 'test', + '--config', + config, + '--list', + '--reporter=json', + ...extra + ], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs: 20000 + }) + expect(result.code, result.stderr).toBe(0) + return JSON.parse(result.stdout) + } + try { + const full = await discover() + const assignment = planE2e(full, 14, { timings: {} }) + const ids = [] + for (let index = 0; index < 14; index++) { + const path = join(directory, 'selected.txt') + writeFileSync(path, `${assignment.shards[index].files.join('\n')}\n`) + const selected = await discover(['--test-list', path]) + verifyE2eSelection({ ...assignment, selectedShard: index + 1 }, selected) + for (const suite of selected.suites) { + expect(suite.specs.some((spec) => spec.title.includes('@headful'))).toBe(false) + } + ids.push(...assignment.shards[index].files.flatMap((file) => assignment.testsByFile[file])) + } + expect(ids).toHaveLength(17 * 4) + expect(new Set(ids).size).toBe(ids.length) + expect(() => verifyE2eSelection({ ...assignment, selectedShard: 1 }, full)).toThrow('differs') + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}, 60000) diff --git a/config/scripts/ci-shard-assignment.mjs b/config/scripts/ci-shard-assignment.mjs new file mode 100644 index 00000000000..1cb44969a63 --- /dev/null +++ b/config/scripts/ci-shard-assignment.mjs @@ -0,0 +1,66 @@ +import { createHash } from 'node:crypto' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' + +export const compareIds = (a, b) => (a < b ? -1 : a > b ? 1 : 0) + +export function balanceFiles(files, count, timings, overheadMs = 0) { + if (!Number.isInteger(count) || count < 1) { + throw new Error('Invalid shard count') + } + if (new Set(files).size !== files.length) { + throw new Error('Duplicate discovered file') + } + const known = Object.values(timings).filter((value) => Number.isFinite(value) && value > 0) + known.sort((a, b) => a - b) + const fallbackMs = known[Math.floor(known.length / 2)] ?? 1000 + const weighted = files.map((file) => ({ + file, + durationMs: + (Number.isFinite(timings[file]) && timings[file] > 0 ? timings[file] : fallbackMs) + + overheadMs + })) + weighted.sort((a, b) => b.durationMs - a.durationMs || compareIds(a.file, b.file)) + const shards = Array.from({ length: count }, () => ({ files: [], durationMs: 0 })) + for (const entry of weighted) { + const target = shards.reduce((best, shard) => + shard.durationMs < best.durationMs || + (shard.durationMs === best.durationMs && shard.files.length < best.files.length) + ? shard + : best + ) + target.files.push(entry.file) + target.durationMs += entry.durationMs + } + for (const shard of shards) { + shard.files.sort(compareIds) + } + const assigned = shards.flatMap((shard) => shard.files).sort(compareIds) + if (JSON.stringify(assigned) !== JSON.stringify([...files].sort(compareIds))) { + throw new Error('Shard coverage differs from discovery') + } + return { algorithm: 'file-lpt-v1', fallbackMs, overheadMs, shards } +} + +export function readTimingBaseline(suite) { + const bytes = readFileSync(new URL('./ci-shard-timings.json', import.meta.url), 'utf8') + const baseline = JSON.parse(bytes) + return { ...baseline[suite], baselineSha256: createHash('sha256').update(bytes).digest('hex') } +} + +export function writeAssignment(path, assignment) { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync( + path, + `${JSON.stringify( + { + sourceSha: process.env.ORCA_SHARD_SOURCE_SHA ?? process.env.GITHUB_SHA ?? null, + runId: process.env.GITHUB_RUN_ID ?? null, + runAttempt: process.env.GITHUB_RUN_ATTEMPT ?? null, + ...assignment + }, + null, + 2 + )}\n` + ) +} diff --git a/config/scripts/ci-shard-assignment.test.mjs b/config/scripts/ci-shard-assignment.test.mjs new file mode 100644 index 00000000000..607a73d392c --- /dev/null +++ b/config/scripts/ci-shard-assignment.test.mjs @@ -0,0 +1,124 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { BaseSequencer } from 'vitest/node' +import { balanceFiles } from './ci-shard-assignment.mjs' +import { discoverE2eFiles, planE2e } from './ci-e2e-shard-plan.mjs' +import { parseTimingLog } from './ci-shard-timing-import.mjs' +import TimingSequencer from './ci-unit-sequencer.mjs' + +const directories = [] +afterEach(() => { + vi.unstubAllEnvs() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('timing-weighted shard selection', () => { + it('distributes long files, includes unknowns exactly once, and ignores discovery order', () => { + const files = ['long', 'medium', 'short', 'unknown', 'new', 'zero', 'invalid'] + const timings = { long: 100, medium: 80, short: 20, zero: 0, invalid: -1, deleted: 20 } + const plan = balanceFiles(files, 3, timings, 10) + expect(plan).toEqual(balanceFiles(files.toReversed(), 3, timings, 10)) + expect(plan.fallbackMs).toBe(80) + expect(plan.shards.flatMap((shard) => shard.files).sort()).toEqual([...files].sort()) + expect(Math.max(...plan.shards.map((shard) => shard.durationMs))).toBeLessThan(250) + }) + + it('has a deterministic cold fallback and permits fewer files than shards', () => { + expect(balanceFiles(['b', 'a'], 3, {}).shards).toEqual([ + { files: ['a'], durationMs: 1000 }, + { files: ['b'], durationMs: 1000 }, + { files: [], durationMs: 0 } + ]) + expect(() => balanceFiles(['a', 'a'], 8, {})).toThrow('Duplicate') + expect(() => balanceFiles(['a'], 0, {})).toThrow('count') + }) + + it('uses the post-filter Vitest discovery unchanged across eight shards and retains default sort', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-unit-shards-')) + directories.push(directory) + vi.stubEnv('ORCA_SHARD_MANIFEST', join(directory, 'assignment.json')) + const specs = Array.from({ length: 37 }, (_, i) => ({ + moduleId: resolve(`src/fixture-${i}.test.ts`) + })) + const selected = [] + for (let index = 1; index <= 8; index++) { + const sequencer = new TimingSequencer({ + config: { root: process.cwd(), shard: { index, count: 8 } } + }) + expect(sequencer.sort).toBe(BaseSequencer.prototype.sort) + selected.push(...(await sequencer.shard(specs))) + const manifest = JSON.parse(readFileSync(join(directory, 'assignment.json'), 'utf8')) + expect(manifest.selectedShard).toBe(index) + expect(manifest.baselineSha256).toMatch(/^[a-f0-9]{64}$/) + } + expect(new Set(selected).size).toBe(specs.length) + expect(selected).toHaveLength(specs.length) + expect(new Set(selected)).toEqual(new Set(specs)) + }) + + it('wires a constructor into the opt-in Vitest config', async () => { + vi.stubEnv('ORCA_BALANCE_UNIT_SHARDS', '1') + const { default: config } = await import('../vitest.config') + expect(config.test.sequence.sequencer).toBe(TimingSequencer) + }) + + it('keeps nested/serial E2E files atomic and fails closed on discovery errors', () => { + const spec = (id, file) => ({ id, file, tests: [{ projectName: 'electron-headless' }] }) + const report = { + suites: [ + { + specs: [spec('a', 'one.spec.ts')], + suites: [{ specs: [spec('b', 'one.spec.ts'), spec('c', 'two.spec.ts')] }] + } + ] + } + const plan = planE2e(report, 14, { timings: { 'tests/e2e/one.spec.ts': 4000 } }) + expect(plan.shards.flatMap((shard) => shard.files).sort()).toEqual([ + 'one.spec.ts', + 'two.spec.ts' + ]) + expect(plan.testsByFile['one.spec.ts']).toHaveLength(2) + expect(() => discoverE2eFiles({ ...report, errors: [{}] })).toThrow('errors') + expect(() => discoverE2eFiles({ suites: [] })).toThrow('no tests') + expect(() => + discoverE2eFiles({ suites: [{ specs: [spec('a', '../escape.spec.ts')] }] }) + ).toThrow('Unsafe') + expect(() => + discoverE2eFiles({ + suites: [{ specs: [spec('a', 'one.spec.ts'), spec('a', 'one.spec.ts')] }] + }) + ).toThrow('Duplicate') + }) + + it('imports ANSI unit timings and E2E failures without counting headful reruns', () => { + const parsed = parseTimingLog( + [ + '\u001b[32m✓\u001b[39m src/a.test.ts (2 tests) 35ms', + 'Duration 1s (transform 0.1s, setup 0.2s, import 0.3s, tests 0.04s, environment 0.4s)', + '✓ 1 [electron-headless] › tests/e2e/a.spec.ts:1:1 › works (2s)', + '✘ 2 [electron-headless] › tests/e2e/a.spec.ts:2:1 › fails (1.2m)', + '✓ 3 [electron-headful] › tests/e2e/a.spec.ts:3:1 › benchmark (9s)' + ].join('\n') + ) + expect(parsed).toEqual({ + unit: { 'src/a.test.ts': 35 }, + e2e: { 'tests/e2e/a.spec.ts': 74000 }, + overheadMs: 1000 + }) + }) + + it('reads mixed units from captured Vitest output', () => { + const parsed = parseTimingLog( + 'Duration 5.14s (transform 952ms, setup 449ms, import 1.18s, tests 9.41s, environment 1ms)' + ) + expect(parsed.overheadMs).toBe(2582) + }) + + it('rejects incomplete unit evidence instead of silently dropping overhead', () => { + expect(() => parseTimingLog('✓ src/a.test.ts (2 tests) 35ms')).toThrow('Duration summary') + }) +}) diff --git a/config/scripts/ci-shard-timing-import.mjs b/config/scripts/ci-shard-timing-import.mjs new file mode 100644 index 00000000000..5b62c0f9f7f --- /dev/null +++ b/config/scripts/ci-shard-timing-import.mjs @@ -0,0 +1,86 @@ +import { readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { stripVTControlCharacters } from 'node:util' +import { pathToFileURL } from 'node:url' + +export function parseTimingLog(text) { + const clean = stripVTControlCharacters(text) + const unit = {} + const e2e = {} + for (const match of clean.matchAll( + /[✓×❯] ([\w./-]+\.test\.(?:ts|tsx|mjs)) \([^\n]*?\)\s+([\d.]+)ms/g + )) { + unit[match[1]] = Number(match[2]) + } + for (const match of clean.matchAll( + /[✓✘]\s+\d+ \[electron-headless\] › (tests\/e2e\/[^:]+):\d+:\d+ › .*? \(([\d.]+)(ms|s|m)\)/g + )) { + e2e[match[1]] = (e2e[match[1]] ?? 0) + Number(match[2]) * { ms: 1, s: 1000, m: 60000 }[match[3]] + } + const summary = clean.match( + /Duration\s+[\d.]+(?:ms|s) \(transform ([\d.]+(?:ms|s)), setup ([\d.]+(?:ms|s)), import ([\d.]+(?:ms|s)), tests [\d.]+(?:ms|s), environment ([\d.]+(?:ms|s))\)/ + ) + if (Object.keys(unit).length && !summary) { + throw new Error('Unit timing log has no supported Duration summary') + } + return { + unit, + e2e, + overheadMs: summary + ? summary + .slice(1) + .reduce( + (sum, value) => sum + Number.parseFloat(value) * (value.endsWith('ms') ? 1 : 1000), + 0 + ) + : 0 + } +} + +export function importTimingLogs(directory, unitRun, e2eRun) { + const baseline = { + unit: { runId: unitRun, jobIds: [], overheadMs: 0, timings: {} }, + e2e: { runId: e2eRun, jobIds: [], overheadMs: 0, timings: {} } + } + for (const file of readdirSync(directory) + .filter((file) => /^log-\d+\.txt$/.test(file)) + .sort()) { + const parsed = parseTimingLog(readFileSync(join(directory, file), 'utf8')) + for (const suite of ['unit', 'e2e']) { + if (!Object.keys(parsed[suite]).length) { + continue + } + baseline[suite].jobIds.push(file.match(/\d+/)[0]) + for (const [name, duration] of Object.entries(parsed[suite])) { + if (suite === 'unit' && name in baseline.unit.timings) { + throw new Error(`Duplicate unit timing: ${name}`) + } + baseline[suite].timings[name] = (baseline[suite].timings[name] ?? 0) + duration + } + } + baseline.unit.overheadMs += parsed.overheadMs + } + for (const suite of ['unit', 'e2e']) { + if (!baseline[suite].jobIds.length) { + throw new Error(`No ${suite} timing evidence`) + } + baseline[suite].timings = Object.fromEntries( + Object.entries(baseline[suite].timings).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + ) + } + baseline.unit.overheadMs = Math.ceil( + baseline.unit.overheadMs / Object.keys(baseline.unit.timings).length + ) + return baseline +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const [directory, unitRun, e2eRun, output] = process.argv.slice(2) + if (!directory || !unitRun || !e2eRun || !output) { + throw new Error('Usage: ci-shard-timing-import.mjs LOG_DIRECTORY UNIT_RUN E2E_RUN OUTPUT') + } + writeFileSync( + output, + `${JSON.stringify(importTimingLogs(directory, unitRun, e2eRun), null, 2)}\n` + ) +} diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json new file mode 100644 index 00000000000..46ea33ce679 --- /dev/null +++ b/config/scripts/ci-shard-timings.json @@ -0,0 +1,8815 @@ +{ + "unit": { + "runId": "34675583768", + "jobIds": [ + "103505449197", + "103505449205", + "103505449211", + "103505449212", + "103505449228", + "103505449235", + "103505449248", + "103505449256" + ], + "overheadMs": 526, + "timings": { + "config/scripts/adhoc-build-version.test.mjs": 31, + "config/scripts/agent-status-hot-path-benchmark.test.ts": 2277, + "config/scripts/app-store-performance-plugin.test.mjs": 912, + "config/scripts/audit-localization-coverage.test.mjs": 23, + "config/scripts/benchmark-artifact-comparison.test.mjs": 132, + "config/scripts/benchmark-sample-summary.test.mjs": 8, + "config/scripts/build-linux-local.test.mjs": 15, + "config/scripts/build-mac-local.test.mjs": 5, + "config/scripts/build-orcad-prebuilds.test.mjs": 27, + "config/scripts/build-windows-cli-launcher.test.mjs": 39, + "config/scripts/check-changed-code-quality.test.mjs": 7, + "config/scripts/check-max-lines-ratchet.test.mjs": 10, + "config/scripts/check-react-doctor-changed.test.mjs": 189, + "config/scripts/check-reliability-gates.test.mjs": 171, + "config/scripts/check-root-directory-entries.test.mjs": 880, + "config/scripts/check-runtime-electron-ratchet.test.mjs": 1931, + "config/scripts/check-terminal-perf-report-budgets.test.mjs": 398, + "config/scripts/check-ts-nocheck-ratchet.test.mjs": 5, + "config/scripts/ci-native-toolchain.test.mjs": 38, + "config/scripts/client-hosted-browser-package-coverage.test.mjs": 81, + "config/scripts/codex-index-heal-contract-workflow.test.mjs": 10, + "config/scripts/codex-primary-home-tripwire.test.ts": 421, + "config/scripts/computer-e2e-workflow.test.mjs": 132, + "config/scripts/computer-use-modifier-safety.test.mjs": 5, + "config/scripts/computer-use-mouse-button-routing.test.mjs": 9, + "config/scripts/computer-use-skill-guidance.test.mjs": 15, + "config/scripts/computer-use-smoke.test.mjs": 471, + "config/scripts/computer-use-windows-horizontal-scroll.test.mjs": 3, + "config/scripts/counterbalanced-benchmark-schedule.test.mjs": 10, + "config/scripts/create-draft-release.test.mjs": 18, + "config/scripts/daily-build-version.test.mjs": 36, + "config/scripts/daily-e2e-dispatch-contract.test.mjs": 6, + "config/scripts/dev-channel-base-version.test.mjs": 8, + "config/scripts/dev-channel-windows-workflow-contract.test.mjs": 218, + "config/scripts/dev-cli-terminal-wrapper.test.mjs": 9, + "config/scripts/dev-electron-bundle-cache.test.ts": 10, + "config/scripts/dev-electron-bundle-identity.test.ts": 7, + "config/scripts/electron-builder-config.test.mjs": 155, + "config/scripts/electron-builder-mac-channel-config.test.mjs": 577, + "config/scripts/electron-builder-markdown-associations.test.mjs": 14, + "config/scripts/electron-builder-native-rebuild.test.mjs": 11, + "config/scripts/electron-builder-runtime-resources.test.mjs": 393, + "config/scripts/electron-builder-speech-config.test.mjs": 16, + "config/scripts/electron-runtime-floor.test.ts": 5, + "config/scripts/electron-vite-output-contract.test.ts": 13, + "config/scripts/ensure-native-runtime-job-ownership.test.mjs": 10, + "config/scripts/ensure-native-runtime.test.mjs": 422, + "config/scripts/generate-bundled-skill-guides.test.mjs": 293, + "config/scripts/generate-runtime-required-english-catalog.test.mjs": 8, + "config/scripts/generate-skill-bundle-manifest.test.mjs": 448, + "config/scripts/generate-terminal-perf-html-report.test.mjs": 19, + "config/scripts/git-binary-compatibility-workflow.test.mjs": 98, + "config/scripts/git-pull-request-diff-base.test.mjs": 4, + "config/scripts/hang-watchdog-process-metrics.test.mjs": 6, + "config/scripts/happy-dom-mutation-observer-retention.test.ts": 100, + "config/scripts/happy-dom-offscreen-canvas.test.ts": 42, + "config/scripts/headless-serve-shutdown-workflow.test.mjs": 15, + "config/scripts/hourly-build-version.test.mjs": 27, + "config/scripts/hourly-preflight-workflow.test.mjs": 33, + "config/scripts/idle-cpu-process-sampling.test.mjs": 5, + "config/scripts/install-electron-package-binary.test.mjs": 2145, + "config/scripts/install-node-dependencies-action.test.mjs": 140, + "config/scripts/latest-stable-release.test.mjs": 10, + "config/scripts/lint-staged-worktree-backup.test.mjs": 276, + "config/scripts/linux-package-maintainer-scripts.test.mjs": 6, + "config/scripts/live-freeze-bounded-history.test.mjs": 6, + "config/scripts/live-remote-bulk-open-freeze-metrics.test.mjs": 17, + "config/scripts/live-remote-freeze-rpc.test.mjs": 5, + "config/scripts/live-remote-status-watchdog.test.mjs": 248, + "config/scripts/locale-count-fragment-separator.test.mjs": 17, + "config/scripts/locale-generic-ui-terms.test.mjs": 20, + "config/scripts/locale-ko-frozen-terminal-search-keywords.test.mjs": 35, + "config/scripts/locale-ko-key-overrides.test.mjs": 38, + "config/scripts/locale-repair-catalog-missing-leaves.test.mjs": 14, + "config/scripts/locale-translation-policy-ko-round5.test.mjs": 12, + "config/scripts/locale-translation-policy.es-pr.test.mjs": 13, + "config/scripts/locale-translation-policy.es-round5.test.mjs": 9, + "config/scripts/locale-translation-policy.ja-relocalization.test.mjs": 17, + "config/scripts/locale-translation-policy.ja-round5.test.mjs": 13, + "config/scripts/locale-translation-policy.test.mjs": 38, + "config/scripts/locale-translation-policy.zh-round5.test.mjs": 14, + "config/scripts/locale-translation-policy.zh-status-bar-usage.test.mjs": 24, + "config/scripts/localization-package-contract.test.mjs": 8, + "config/scripts/mac-build-compatibility.test.mjs": 8, + "config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs": 10, + "config/scripts/macos-tcc-prompt-localization.test.mjs": 75, + "config/scripts/mobile-pairing-qrcode-import-plugin.test.mjs": 450, + "config/scripts/node-old-space-limit.test.mjs": 6, + "config/scripts/node-pty-console-list-agent-patch.test.mjs": 7, + "config/scripts/node-pty-master-cloexec-patch.test.mjs": 22, + "config/scripts/node-pty-windows-pty-teardown-patch.test.mjs": 59, + "config/scripts/orca-cli-skill-guidance.test.mjs": 14, + "config/scripts/orca-dev-bin.test.mjs": 68, + "config/scripts/orca-linear-skill-guidance.test.mjs": 15, + "config/scripts/orcad-operations-restart-safety.test.mjs": 5, + "config/scripts/orchestration-guide-command-contract.test.mjs": 11, + "config/scripts/orchestration-skill-guidance.test.mjs": 23, + "config/scripts/oxc-cli-invocation.test.mjs": 128, + "config/scripts/oxlint-cli-invocation.test.mjs": 46, + "config/scripts/package-electron-runtime-contract.test.mjs": 360, + "config/scripts/packaged-browser-lane-contract.test.mjs": 7, + "config/scripts/packaged-hang-watchdog-worker-contract.test.mjs": 55, + "config/scripts/packaged-node-pty-prebuild-prune.test.mjs": 25, + "config/scripts/packaged-source-map-prune.test.mjs": 53, + "config/scripts/patched-dependencies-frozen-install.test.mjs": 451, + "config/scripts/plain-node-entry-guard.test.ts": 610, + "config/scripts/pnpm-cli-invocation.test.mjs": 6, + "config/scripts/pr-code-change-scope.test.mjs": 346, + "config/scripts/pr-e2e-gate-contract.test.mjs": 108, + "config/scripts/pr-e2e-native-only-routing.test.mjs": 19, + "config/scripts/pr-test-loc-summary.test.mjs": 89, + "config/scripts/pr-workflow-lint-parity.test.mjs": 53, + "config/scripts/pr-workflow-parallelism.test.mjs": 898, + "config/scripts/project-renderer-web-client.test.mjs": 213, + "config/scripts/pty-transcript-secret-scan.test.mjs": 14, + "config/scripts/publish-complete-draft-releases.test.mjs": 168, + "config/scripts/quadratic-buffer-concat-plugin.test.mjs": 2010, + "config/scripts/rebuild-native-deps-node-pty.test.mjs": 2338, + "config/scripts/rebuild-native-deps.test.mjs": 831, + "config/scripts/reclaim-dev-electron-bundles.test.ts": 35, + "config/scripts/regenerate-xterm-patches.test.mjs": 300, + "config/scripts/relay-artifact-manifest.test.mjs": 3554, + "config/scripts/relay-asset-line-ending-pin.test.mjs": 74, + "config/scripts/release-blocker-fixes.test.mjs": 39, + "config/scripts/release-cut-signpath-slack.test.mjs": 72, + "config/scripts/release-cut-sourcemap-publish.test.mjs": 11, + "config/scripts/release-cut-token-permissions.test.mjs": 19, + "config/scripts/release-e2e-dispatch-contract.test.mjs": 8, + "config/scripts/release-mac-build-workflow-dispatch.test.mjs": 10, + "config/scripts/release-rc-history.test.mjs": 166, + "config/scripts/renderer-boot-graph.test.mjs": 54, + "config/scripts/renderer-scrollbar-style-plugin.test.mjs": 2888, + "config/scripts/replace-cached-nsis-elevate.test.mjs": 1880, + "config/scripts/resolve-7za-path.test.mjs": 1412, + "config/scripts/run-codex-real-account-validation.test.ts": 3652, + "config/scripts/run-internal-dev-setup.test.mjs": 10, + "config/scripts/run-linux-packaged-node-pty-floor-smoke.test.mjs": 10, + "config/scripts/run-terminal-scale-perf-report-gate.test.mjs": 17, + "config/scripts/shared-electron-dist-cache.test.ts": 44, + "config/scripts/shebang-script-line-ending-pin.test.mjs": 77, + "config/scripts/skill-critical-guidance.test.mjs": 6, + "config/scripts/skill-description-length.test.mjs": 26, + "config/scripts/skill-recipe-shell.test.mjs": 43, + "config/scripts/skill-sharing-release-workflow.test.mjs": 12, + "config/scripts/skill-update-roundtrip-workflow.test.mjs": 5, + "config/scripts/skills-cli-package-workflow.test.mjs": 8, + "config/scripts/sort-comparator-performance-plugin.test.mjs": 269, + "config/scripts/space-sharing-copy.test.ts": 40, + "config/scripts/ssh-browser-e2e-routing.test.mjs": 23, + "config/scripts/ssh-localhost-e2e-routing.test.mjs": 28, + "config/scripts/static-appimage-package-contract.test.mjs": 42, + "config/scripts/summarize-terminal-perf-report.test.mjs": 52, + "config/scripts/telemetry-bundle-constant-patterns.test.mjs": 4, + "config/scripts/terminal-ime-e2e-workflow.test.mjs": 9, + "config/scripts/terminal-ime-engagement-receipt.test.mjs": 11, + "config/scripts/terminal-perf-report-annotations.test.mjs": 4, + "config/scripts/trim-windows-icon-source.test.mjs": 121, + "config/scripts/verify-cli-bin.test.mjs": 64, + "config/scripts/verify-dev-channel-packaging.test.mjs": 666, + "config/scripts/verify-linux-glibc-floor.test.mjs": 118, + "config/scripts/verify-localization-catalog.test.mjs": 46, + "config/scripts/verify-localization-extraction.test.mjs": 5, + "config/scripts/verify-packaged-browser-participation.test.mjs": 10, + "config/scripts/verify-packaged-daemon-entry.test.mjs": 111, + "config/scripts/verify-packaged-node-pty-job-ownership.test.mjs": 10, + "config/scripts/verify-packaged-plugin-resources.test.mjs": 67, + "config/scripts/verify-release-required-assets.test.mjs": 8, + "config/scripts/verify-skills-cli-runtime.test.mjs": 141, + "config/scripts/verify-windows-inner-signature.test.mjs": 20, + "config/scripts/verify-wsl-e2e-participation.test.mjs": 9, + "config/scripts/websocket-server-loopback-bind.test.ts": 5, + "config/scripts/win-crash-survival-e2e.test.mjs": 12, + "config/scripts/win32-test-lane-registration.test.mjs": 22, + "config/scripts/windows-cmd-shim-spawn-boundary.test.mjs": 6, + "config/scripts/windows-process-tree-gyp-path.test.mjs": 42, + "config/scripts/windows-process-tree-gyp-rebuild.test.mjs": 49, + "config/scripts/windows-process-tree-patch-contract.test.mjs": 530, + "config/scripts/windows-pty-native-capability-workflow.test.mjs": 7, + "config/scripts/windows-signing-gate-toolset.test.mjs": 53, + "config/scripts/windows-signing-workflow-contract.test.mjs": 531, + "config/scripts/windows-uninstaller-signing.test.mjs": 17, + "config/scripts/workflow-ref-mirror-case-safety.test.mjs": 74, + "config/scripts/workflow-ref-reachability.test.mjs": 592, + "config/scripts/wsl-e2e-lane-contract.test.mjs": 76, + "config/scripts/xterm-webgl-runtime-contract.test.mjs": 19, + "src/cli/agent-context.test.ts": 16, + "src/cli/args.test.ts": 36, + "src/cli/automation-format.test.ts": 16, + "src/cli/automation-owner-conflict-recovery.test.ts": 12, + "src/cli/base64-payload-byte-count.test.ts": 3, + "src/cli/browser-cookie-credentials-empty-value.test.ts": 58, + "src/cli/browser-storage-empty-value.test.ts": 48, + "src/cli/browser.test.ts": 109, + "src/cli/cli-command-name-parity.test.ts": 7, + "src/cli/cli-version.test.ts": 13, + "src/cli/codex-command-classification.test.ts": 11, + "src/cli/command-suggestion-budget.test.ts": 25, + "src/cli/command-suggestion.test.ts": 20, + "src/cli/computer-format.test.ts": 3, + "src/cli/emulator-logcat-format.test.ts": 4, + "src/cli/execution-host-flag.test.ts": 21, + "src/cli/flags.test.ts": 4, + "src/cli/format-recovery.test.ts": 10, + "src/cli/format.test.ts": 29, + "src/cli/handler-group-manifest.test.ts": 638, + "src/cli/handlers/account.test.ts": 558, + "src/cli/handlers/agent-hooks.test.ts": 83, + "src/cli/handlers/artifacts.test.ts": 36, + "src/cli/handlers/automation-destination-fencing.test.ts": 17, + "src/cli/handlers/automation-owner-fencing.test.ts": 16, + "src/cli/handlers/computer-action-routing.test.ts": 69, + "src/cli/handlers/computer-action-validation.test.ts": 86, + "src/cli/handlers/computer-state-formatting.test.ts": 65, + "src/cli/handlers/computer.test.ts": 103, + "src/cli/handlers/core.test.ts": 7, + "src/cli/handlers/emulator.test.ts": 37, + "src/cli/handlers/file-absolute-paths.test.ts": 60, + "src/cli/handlers/file.test.ts": 46, + "src/cli/handlers/interactive-login-interruption.test.ts": 12, + "src/cli/handlers/linear.test.ts": 131, + "src/cli/handlers/orchestration-caller-identity-cli.test.ts": 18, + "src/cli/handlers/orchestration-check-identity.test.ts": 12, + "src/cli/handlers/orchestration-federated-legacy-settlement.test.ts": 8, + "src/cli/handlers/orchestration-gate-cli.test.ts": 99, + "src/cli/handlers/orchestration-legacy-read-only.test.ts": 9, + "src/cli/handlers/orchestration-lifecycle-json-rejection.test.ts": 15, + "src/cli/handlers/orchestration-lifecycle-rejection.test.ts": 12, + "src/cli/handlers/orchestration-migration.test.ts": 12, + "src/cli/handlers/orchestration-module-boundaries.test.ts": 11, + "src/cli/handlers/orchestration-request-show-cli.test.ts": 7, + "src/cli/handlers/orchestration-run-cli.test.ts": 22, + "src/cli/handlers/orchestration-send-receipt-warnings.test.ts": 7, + "src/cli/handlers/orchestration-task-create-cli.test.ts": 8, + "src/cli/handlers/orchestration-task-list-brief.test.ts": 12, + "src/cli/handlers/orchestration-timeout-cli.test.ts": 29, + "src/cli/handlers/orchestration-timeout.test.ts": 26, + "src/cli/handlers/orchestration-windows-ask-cli.test.ts": 11, + "src/cli/handlers/orchestration-worker-cli.test.ts": 32, + "src/cli/handlers/orchestration-worker-show-wait-cli.test.ts": 9, + "src/cli/handlers/orchestration.test.ts": 31, + "src/cli/handlers/orchestration/worker-list-run-scope.test.ts": 13, + "src/cli/handlers/orchestration/worker-output.test.ts": 13, + "src/cli/handlers/skill-sharing.test.ts": 16, + "src/cli/handlers/terminal.test.ts": 29, + "src/cli/host-selector-alternatives.test.ts": 14, + "src/cli/index-automation-identifiers.test.ts": 42, + "src/cli/index-automation-schedule.test.ts": 64, + "src/cli/index-automation-session-reuse.test.ts": 54, + "src/cli/index-automation-source-context.test.ts": 75, + "src/cli/index-automation-target.test.ts": 89, + "src/cli/index-device-commands.test.ts": 57, + "src/cli/index-environment-commands.test.ts": 103, + "src/cli/index-local-command-routing-flags.test.ts": 141, + "src/cli/index-memory-diagnostics.test.ts": 54, + "src/cli/index-omitted-host-scope-selectors.test.ts": 54, + "src/cli/index-orchestration.test.ts": 72, + "src/cli/index-project-setup.test.ts": 95, + "src/cli/index-serve-command.test.ts": 121, + "src/cli/index-terminal-commands.test.ts": 78, + "src/cli/index-terminal-list-host-scope.test.ts": 92, + "src/cli/index-vm-recipe-doctor.test.ts": 125, + "src/cli/index-worktree-create-agent.test.ts": 64, + "src/cli/index-worktree-create-linear.test.ts": 44, + "src/cli/index-worktree-create-parent.test.ts": 118, + "src/cli/index-worktree-create-target.test.ts": 65, + "src/cli/index-worktree-selector-resolution.test.ts": 185, + "src/cli/index-worktree-set.test.ts": 83, + "src/cli/index.test.ts": 263, + "src/cli/linear-format.test.ts": 9, + "src/cli/main-module-bundle-parity.test.ts": 14, + "src/cli/orchestration-dispatch-refusal-format.test.ts": 8, + "src/cli/orchestration-mutation-recovery.test.ts": 52, + "src/cli/orchestration-structured-sender-identity.test.ts": 93, + "src/cli/orchestration-structured-session-no-identity.test.ts": 11, + "src/cli/quote-stripped-json-flag.test.ts": 8, + "src/cli/registry-parity.test.ts": 7, + "src/cli/retry-request-flag.test.ts": 9, + "src/cli/runtime-client-deferral.test.ts": 486, + "src/cli/runtime-client.test.ts": 110, + "src/cli/runtime/client-recovery.test.ts": 250, + "src/cli/runtime/client-timeout-policy.test.ts": 13, + "src/cli/runtime/envelope-schema.test.ts": 33, + "src/cli/runtime/environments.test.ts": 27, + "src/cli/runtime/launch.test.ts": 53, + "src/cli/runtime/orchestration-recovery-command.test.ts": 5, + "src/cli/runtime/serve-signal-exit-diagnostic.test.ts": 28, + "src/cli/runtime/status.test.ts": 19, + "src/cli/runtime/transport-framing.test.ts": 78, + "src/cli/runtime/transport.test.ts": 533, + "src/cli/runtime/types.test.ts": 5, + "src/cli/runtime/websocket-transport-error.test.ts": 8, + "src/cli/runtime/websocket-transport.test.ts": 176, + "src/cli/serve-electron-flag-parity.test.ts": 8, + "src/cli/shell-command-quote.test.ts": 8, + "src/cli/skill-guide-cli-parity.test.ts": 14, + "src/cli/skills-reference-selector.test.ts": 28, + "src/cli/skills.test.ts": 963, + "src/cli/specs/account.test.ts": 6, + "src/cli/specs/bundled-guide-flags.test.ts": 40, + "src/cli/specs/computer.test.ts": 9, + "src/cli/specs/orchestration.test.ts": 16, + "src/cli/specs/skills.test.ts": 6, + "src/cli/terminal-format-draft.test.ts": 6, + "src/cli/terminal-format.test.ts": 12, + "src/cli/terminal-list-host-scope-format.test.ts": 8, + "src/cli/terminal-read-screen.test.ts": 68, + "src/cli/vocabulary-policy.test.ts": 8, + "src/cli/worktree-selector-wsl-posix-path.test.ts": 11, + "src/main/active-view-persistence-boundary.test.ts": 725, + "src/main/active-view-preference-sync-flush-veto.test.ts": 777, + "src/main/active-view-preference.test.ts": 20, + "src/main/agent-auth-restart-preservation.test.ts": 16, + "src/main/agent-awake-service-platform-assertions.test.ts": 10, + "src/main/agent-awake-service.test.ts": 29, + "src/main/agent-hooks/branch-rename-failure-output.test.ts": 7, + "src/main/agent-hooks/ended-process-reconciliation.test.ts": 34, + "src/main/agent-hooks/first-work-branch-rename.test.ts": 140, + "src/main/agent-hooks/first-work-folder-rename.test.ts": 13, + "src/main/agent-hooks/hook-script-outside-orca.test.ts": 32, + "src/main/agent-hooks/hook-status-session-tabs-republish.test.ts": 22, + "src/main/agent-hooks/install-telemetry.test.ts": 10, + "src/main/agent-hooks/installer-utils-remote.test.ts": 14, + "src/main/agent-hooks/installer-utils.test.ts": 66, + "src/main/agent-hooks/local-agent-cli-presence.test.ts": 11, + "src/main/agent-hooks/managed-agent-hook-controls.test.ts": 76, + "src/main/agent-hooks/managed-hook-detection-commands.test.ts": 5, + "src/main/agent-hooks/managed-hook-install-lock.test.ts": 263, + "src/main/agent-hooks/managed-hook-local-filesystem.test.ts": 147, + "src/main/agent-hooks/managed-hook-owner-identity.test.ts": 33, + "src/main/agent-hooks/managed-hook-runtime.test.ts": 41, + "src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts": 27, + "src/main/agent-hooks/managed-hook-script-refresh.test.ts": 108, + "src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts": 1979, + "src/main/agent-hooks/managed-hook-timeout.test.ts": 1585, + "src/main/agent-hooks/managed-toml-ownership.test.ts": 12, + "src/main/agent-hooks/manual-compact-hook-stream.test.ts": 119, + "src/main/agent-hooks/manual-compact-status-cleanup.test.ts": 6, + "src/main/agent-hooks/migration-unsupported-pty-state.test.ts": 8, + "src/main/agent-hooks/opencode-message-part-flood-bench.test.ts": 1234, + "src/main/agent-hooks/remote-hook-service-installers.test.ts": 89, + "src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts": 75, + "src/main/agent-hooks/server-ai-vault-liveness.test.ts": 34, + "src/main/agent-hooks/server-amp-normalization.test.ts": 13, + "src/main/agent-hooks/server-authority-evidence.test.ts": 21, + "src/main/agent-hooks/server-claude-child-permission-lifecycle.test.ts": 213, + "src/main/agent-hooks/server-claude-normalization.test.ts": 36, + "src/main/agent-hooks/server-claude-permission-visibility.test.ts": 114, + "src/main/agent-hooks/server-claude-statusline.test.ts": 41, + "src/main/agent-hooks/server-closed-tab-suppression.test.ts": 167, + "src/main/agent-hooks/server-codex-normalization.test.ts": 14, + "src/main/agent-hooks/server-codex-subagent-transcript.test.ts": 4092, + "src/main/agent-hooks/server-copilot-normalization.test.ts": 416, + "src/main/agent-hooks/server-cursor-normalization.test.ts": 13, + "src/main/agent-hooks/server-droid-normalization.test.ts": 10, + "src/main/agent-hooks/server-endpoint-file-lifecycle.test.ts": 35, + "src/main/agent-hooks/server-gemini-normalization.test.ts": 13, + "src/main/agent-hooks/server-grok-discovery.test.ts": 513, + "src/main/agent-hooks/server-hook-http-ingest.test.ts": 94, + "src/main/agent-hooks/server-ingest-remote.test.ts": 45, + "src/main/agent-hooks/server-ingest-structured-status.test.ts": 34, + "src/main/agent-hooks/server-ingest-terminal-status.test.ts": 18, + "src/main/agent-hooks/server-interrupt-inference-guards.test.ts": 76, + "src/main/agent-hooks/server-interrupt-inference-resurrection.test.ts": 35, + "src/main/agent-hooks/server-interrupt-inference-validation.test.ts": 24, + "src/main/agent-hooks/server-last-status-hydrate-confirmation.test.ts": 75, + "src/main/agent-hooks/server-last-status-hydrate-validation.test.ts": 83, + "src/main/agent-hooks/server-last-status-lead-boundary.test.ts": 172, + "src/main/agent-hooks/server-last-status-restored-children.test.ts": 596, + "src/main/agent-hooks/server-last-status-write.test.ts": 125, + "src/main/agent-hooks/server-observation-provenance.test.ts": 27, + "src/main/agent-hooks/server-opencode-lifecycle.test.ts": 228, + "src/main/agent-hooks/server-opencode-normalization.test.ts": 15, + "src/main/agent-hooks/server-pane-authority.test.ts": 254, + "src/main/agent-hooks/server-pi-normalization.test.ts": 14, + "src/main/agent-hooks/server-prompt-sent-telemetry.test.ts": 59, + "src/main/agent-hooks/server-relay-listener-replay.test.ts": 25, + "src/main/agent-hooks/server-reminted-pane-key.test.ts": 112, + "src/main/agent-hooks/server-replay-evidence-clock.test.ts": 28, + "src/main/agent-hooks/server-retired-pane-new-turn.test.ts": 21, + "src/main/agent-hooks/server-start-failure-lifecycle.test.ts": 32, + "src/main/agent-hooks/server-status-listener-fanout.test.ts": 84, + "src/main/agent-hooks/server-transport-interference.test.ts": 5826, + "src/main/agent-hooks/server.claude-interactive-question.test.ts": 21, + "src/main/agent-hooks/spool.test.ts": 134, + "src/main/agent-hooks/terminal-handle-row-identity.test.ts": 40, + "src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts": 8, + "src/main/agent-hooks/windows-hook-payload-delivery.test.ts": 8, + "src/main/agent-hooks/windows-hook-post-interpreter.test.ts": 83, + "src/main/agent-hooks/windows-powershell-hook-launcher.test.ts": 5, + "src/main/agent-hooks/wsl-guest-plugin-install.test.ts": 9, + "src/main/agent-hooks/wsl-hook-relay-launch.test.ts": 7, + "src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts": 2716, + "src/main/agent-hooks/wsl-hook-relay-manager.test.ts": 1110, + "src/main/agent-hooks/wsl-hook-relay-reattach.test.ts": 6, + "src/main/agent-hooks/wsl-hook-relay-recovery.test.ts": 2323, + "src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts": 17, + "src/main/agent-state-file-reader.test.ts": 39, + "src/main/agent-trust-presets.test.ts": 33, + "src/main/ai-vault-search/session-search-content-hash.test.ts": 5, + "src/main/ai-vault-search/session-search-cwd-key.test.ts": 9, + "src/main/ai-vault-search/session-search-deleted-sources.test.ts": 176, + "src/main/ai-vault-search/session-search-directory-listings.test.ts": 10, + "src/main/ai-vault-search/session-search-engine.test.ts": 1166, + "src/main/ai-vault-search/session-search-file-write.test.ts": 423, + "src/main/ai-vault-search/session-search-fts5-contract.test.ts": 62, + "src/main/ai-vault-search/session-search-hit-ranking.test.ts": 13, + "src/main/ai-vault-search/session-search-identifier-split.test.ts": 8, + "src/main/ai-vault-search/session-search-index-consumer.test.ts": 172, + "src/main/ai-vault-search/session-search-index-generation.test.ts": 111, + "src/main/ai-vault-search/session-search-index-pass.test.ts": 134, + "src/main/ai-vault-search/session-search-index-writer.test.ts": 114, + "src/main/ai-vault-search/session-search-indexer.test.ts": 2283, + "src/main/ai-vault-search/session-search-lifecycle-matrix.test.ts": 4538, + "src/main/ai-vault-search/session-search-live-transcript.test.ts": 83, + "src/main/ai-vault-search/session-search-merged-roots.test.ts": 111, + "src/main/ai-vault-search/session-search-message-rows.test.ts": 787, + "src/main/ai-vault-search/session-search-native-chat-indexing.test.ts": 211, + "src/main/ai-vault-search/session-search-opencode-decline.test.ts": 218, + "src/main/ai-vault-search/session-search-orphan-rows.test.ts": 179, + "src/main/ai-vault-search/session-search-paging.test.ts": 497, + "src/main/ai-vault-search/session-search-query-planner.test.ts": 14, + "src/main/ai-vault-search/session-search-read-decision.test.ts": 4, + "src/main/ai-vault-search/session-search-retention-delete.test.ts": 108, + "src/main/ai-vault-search/session-search-retention-policy.test.ts": 4, + "src/main/ai-vault-search/session-search-row-filter.test.ts": 194, + "src/main/ai-vault-search/session-search-row-identity.test.ts": 52, + "src/main/ai-vault-search/session-search-scan-roots.test.ts": 5, + "src/main/ai-vault-search/session-search-schema.test.ts": 322, + "src/main/ai-vault-search/session-search-sidebar-parity.test.ts": 213, + "src/main/ai-vault-search/session-search-snippet-marks.test.ts": 118, + "src/main/ai-vault-search/session-search-store-is-memory.test.ts": 305, + "src/main/ai-vault-search/session-search-synthetic-corpus.test.ts": 188, + "src/main/ai-vault-search/session-search-typo-policy.test.ts": 556, + "src/main/ai-vault-search/session-search-typo-scope.test.ts": 47, + "src/main/ai-vault/ai-vault-scan-cancellation.test.ts": 5, + "src/main/ai-vault/ai-vault-scan-coordinator.test.ts": 16, + "src/main/ai-vault/cached-session-list.test.ts": 10, + "src/main/ai-vault/claude-project-dir-encoding.test.ts": 10, + "src/main/ai-vault/codex-session-collection.test.ts": 32, + "src/main/ai-vault/codex-session-root-dedup.test.ts": 13, + "src/main/ai-vault/local-log-tail-reader.test.ts": 25, + "src/main/ai-vault/remote-session-parse-cache.test.ts": 26, + "src/main/ai-vault/remote-session-scan-batching.test.ts": 6, + "src/main/ai-vault/remote-session-scanner-omp-subagents.test.ts": 29, + "src/main/ai-vault/remote-session-scanner.test.ts": 37, + "src/main/ai-vault/remote-session-sidecar-observation.test.ts": 31, + "src/main/ai-vault/runtime-session-scanner.test.ts": 16, + "src/main/ai-vault/session-delete-target.test.ts": 20, + "src/main/ai-vault/session-delete.test.ts": 22, + "src/main/ai-vault/session-first-user-prompt-read.test.ts": 28, + "src/main/ai-vault/session-list-result-validation.test.ts": 27, + "src/main/ai-vault/session-list-results.test.ts": 12, + "src/main/ai-vault/session-newest-files.test.ts": 121, + "src/main/ai-vault/session-parse-cache-persistence.test.ts": 73, + "src/main/ai-vault/session-scanner-antigravity-parser.test.ts": 9, + "src/main/ai-vault/session-scanner-antigravity-source.test.ts": 57, + "src/main/ai-vault/session-scanner-background.test.ts": 8, + "src/main/ai-vault/session-scanner-claude-cwd-drift.test.ts": 23, + "src/main/ai-vault/session-scanner-claude-subagent-prune.test.ts": 21, + "src/main/ai-vault/session-scanner-claude-subagents.test.ts": 71, + "src/main/ai-vault/session-scanner-claude-title.test.ts": 32, + "src/main/ai-vault/session-scanner-claude-unicode-scope.test.ts": 43, + "src/main/ai-vault/session-scanner-cline-parser.test.ts": 32, + "src/main/ai-vault/session-scanner-codex-dual-root.test.ts": 51, + "src/main/ai-vault/session-scanner-codex-fast-path.test.ts": 180, + "src/main/ai-vault/session-scanner-codex-parser.test.ts": 47, + "src/main/ai-vault/session-scanner-codex-title-index.test.ts": 175, + "src/main/ai-vault/session-scanner-codex-tool-records.test.ts": 16, + "src/main/ai-vault/session-scanner-codex-workers.test.ts": 36, + "src/main/ai-vault/session-scanner-core-parser-wsl-stall.test.ts": 45, + "src/main/ai-vault/session-scanner-cursor-chat-meta.test.ts": 154, + "src/main/ai-vault/session-scanner-dedup-batches.test.ts": 228, + "src/main/ai-vault/session-scanner-devin-parser.test.ts": 13, + "src/main/ai-vault/session-scanner-directory-reader.test.ts": 15, + "src/main/ai-vault/session-scanner-discovery-wsl-gate.test.ts": 16, + "src/main/ai-vault/session-scanner-first-user-prompt.test.ts": 9, + "src/main/ai-vault/session-scanner-fs-import-guard.test.ts": 28, + "src/main/ai-vault/session-scanner-graph-parsers.test.ts": 6, + "src/main/ai-vault/session-scanner-grok-parser.test.ts": 26, + "src/main/ai-vault/session-scanner-grok-user-text.test.ts": 6, + "src/main/ai-vault/session-scanner-index-cache-wsl-stall.test.ts": 25, + "src/main/ai-vault/session-scanner-injected-title.test.ts": 29, + "src/main/ai-vault/session-scanner-jsonl-reader.test.ts": 84, + "src/main/ai-vault/session-scanner-kimi-index-cache.test.ts": 31, + "src/main/ai-vault/session-scanner-kimi-parser.test.ts": 153, + "src/main/ai-vault/session-scanner-omp-subagent-listing.test.ts": 24, + "src/main/ai-vault/session-scanner-omp-subagent-prune.test.ts": 38, + "src/main/ai-vault/session-scanner-omp-subagent-transcripts.test.ts": 15, + "src/main/ai-vault/session-scanner-opencode-parser.test.ts": 25, + "src/main/ai-vault/session-scanner-opencode-sources-wsl-stall.test.ts": 23, + "src/main/ai-vault/session-scanner-opencode-sources.test.ts": 9, + "src/main/ai-vault/session-scanner-opencode-sqlite-bounds.test.ts": 539, + "src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts": 58, + "src/main/ai-vault/session-scanner-opencode-sqlite-open.test.ts": 4131, + "src/main/ai-vault/session-scanner-opencode-sqlite-paths.test.ts": 9, + "src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.test.ts": 23, + "src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.test.ts": 6, + "src/main/ai-vault/session-scanner-opencode-sqlite.test.ts": 225, + "src/main/ai-vault/session-scanner-parse-cache-agents.test.ts": 198, + "src/main/ai-vault/session-scanner-parse-cache.test.ts": 57, + "src/main/ai-vault/session-scanner-parse-wsl-stall.test.ts": 31, + "src/main/ai-vault/session-scanner-parser-stream-cleanup.test.ts": 21, + "src/main/ai-vault/session-scanner-preview-window-truncation.test.ts": 6, + "src/main/ai-vault/session-scanner-recoverable-empty.test.ts": 57, + "src/main/ai-vault/session-scanner-roots.test.ts": 7, + "src/main/ai-vault/session-scanner-scope.test.ts": 82, + "src/main/ai-vault/session-scanner-service-client.test.ts": 178, + "src/main/ai-vault/session-scanner-service-entry-path.test.ts": 5, + "src/main/ai-vault/session-scanner-service-entry.test.ts": 486, + "src/main/ai-vault/session-scanner-service-env.test.ts": 8, + "src/main/ai-vault/session-scanner-service-restart-policy.test.ts": 6, + "src/main/ai-vault/session-scanner-service-spawn.test.ts": 6, + "src/main/ai-vault/session-scanner-values.test.ts": 21, + "src/main/ai-vault/session-scanner-worker-client.test.ts": 17, + "src/main/ai-vault/session-scanner.test.ts": 109, + "src/main/ai-vault/session-sidecar-stat.test.ts": 9, + "src/main/ai-vault/session-title-file-reader-wsl-stall.test.ts": 22, + "src/main/ai-vault/session-title-file-reader.test.ts": 15, + "src/main/ai-vault/session-transcript-consumers.test.ts": 107, + "src/main/ai-vault/session-transcript-message-content.test.ts": 10, + "src/main/ai-vault/ssh-session-list.test.ts": 87, + "src/main/ai-vault/structured-session-ownership.test.ts": 18, + "src/main/amp/hook-service.test.ts": 13, + "src/main/antigravity/hook-service.test.ts": 61, + "src/main/antigravity/windows-hook-payload-delivery.test.ts": 10, + "src/main/app-icon.test.ts": 24, + "src/main/app-relaunch.test.ts": 7, + "src/main/appimage-runtime-identity.test.ts": 36, + "src/main/appkit-scene-mutation.test.ts": 9, + "src/main/artifacts/artifact-cloud-config.test.ts": 6, + "src/main/artifacts/artifact-cloud-recovery.test.ts": 132, + "src/main/artifacts/artifact-cloud-service-races.test.ts": 219, + "src/main/artifacts/artifact-cloud-service.test.ts": 725, + "src/main/artifacts/artifact-create-intent-store.test.ts": 339, + "src/main/artifacts/artifact-recovery-directory-fsync.test.ts": 14, + "src/main/artifacts/artifact-share-record-store.test.ts": 310, + "src/main/asar-transparent-fs.test.ts": 9, + "src/main/automations/automation-dispatch-host-fence.test.ts": 1300, + "src/main/automations/automation-owner-fencing.test.ts": 1874, + "src/main/automations/automation-owner-migration.test.ts": 17, + "src/main/automations/automation-run-terminal-surface.test.ts": 34, + "src/main/automations/automation-run-writer.test.ts": 7, + "src/main/automations/automation-skip-coalescing.test.ts": 1783, + "src/main/automations/automation-ssh-readoption-migration.test.ts": 9, + "src/main/automations/automation-update-host-retarget.test.ts": 1187, + "src/main/automations/automation-workspace-host-attribution.test.ts": 757, + "src/main/automations/external-automation-manager-cache.test.ts": 12, + "src/main/automations/external-automation-owner-guard.test.ts": 8, + "src/main/automations/external-automation-probe-scheduler.test.ts": 123, + "src/main/automations/external-job-run-sorting.test.ts": 170, + "src/main/automations/external-manager-scoped.test.ts": 23, + "src/main/automations/external-manager.test.ts": 15, + "src/main/automations/headless-workspace-create.test.ts": 9, + "src/main/automations/hermes-cron-output.test.ts": 73, + "src/main/automations/precheck-runner.test.ts": 1066, + "src/main/automations/refused-manual-run.test.ts": 2290, + "src/main/automations/retained-run-reconciliation.test.ts": 491, + "src/main/automations/run-completion-watcher.test.ts": 1046, + "src/main/automations/run-target-resolution.test.ts": 6, + "src/main/automations/runtime-terminal-run-observer.test.ts": 54, + "src/main/automations/service-precheck.test.ts": 931, + "src/main/automations/service.test.ts": 1218, + "src/main/azure-devops/azure-devops-api-request.test.ts": 61, + "src/main/azure-devops/client.test.ts": 84, + "src/main/azure-devops/pull-request-creation.test.ts": 74, + "src/main/azure-devops/pull-request-mappers.test.ts": 5, + "src/main/azure-devops/repository-ref.test.ts": 33, + "src/main/bitbucket/client.test.ts": 102, + "src/main/bitbucket/credential-connection.test.ts": 151, + "src/main/bitbucket/credential-store.test.ts": 146, + "src/main/bitbucket/pull-request-creation.test.ts": 70, + "src/main/bitbucket/pull-request-mappers.test.ts": 7, + "src/main/bitbucket/repository-ref.test.ts": 24, + "src/main/bitbucket/status-no-decrypt.test.ts": 171, + "src/main/browser/agent-browser-bridge-automation-visibility.test.ts": 91, + "src/main/browser/agent-browser-bridge-command-transport.test.ts": 102, + "src/main/browser/agent-browser-bridge-mouse-input.test.ts": 24, + "src/main/browser/agent-browser-bridge-navigation.test.ts": 156, + "src/main/browser/agent-browser-bridge-session-lifecycle.test.ts": 244, + "src/main/browser/agent-browser-bridge-tab-routing.test.ts": 89, + "src/main/browser/agent-browser-bridge-text-input.test.ts": 104, + "src/main/browser/agent-browser-orphan-sweep.test.ts": 14, + "src/main/browser/agent-browser-process-environment.test.ts": 9, + "src/main/browser/agent-browser-session-reset.test.ts": 9, + "src/main/browser/browser-certificate-trust-controller.test.ts": 186, + "src/main/browser/browser-clicked-link-routing.test.ts": 111, + "src/main/browser/browser-client-download-relay.test.ts": 39, + "src/main/browser/browser-client-download-routing.test.ts": 12, + "src/main/browser/browser-client-file-channel-negotiation.test.ts": 13, + "src/main/browser/browser-client-file-channel-reconnect.test.ts": 317, + "src/main/browser/browser-client-host-attach-request.test.ts": 13, + "src/main/browser/browser-client-host-authority-replacement.test.ts": 20, + "src/main/browser/browser-client-host-command-dispatcher.test.ts": 26, + "src/main/browser/browser-client-host-id.test.ts": 37, + "src/main/browser/browser-client-host-placement-preparation.test.ts": 17, + "src/main/browser/browser-client-host-published-url-wiring.test.ts": 451, + "src/main/browser/browser-client-host-reconciliation-command-order.test.ts": 19, + "src/main/browser/browser-client-network-route-registry.test.ts": 45, + "src/main/browser/browser-client-page-automation-runtime.test.ts": 15, + "src/main/browser/browser-client-page-command-executor-fencing.test.ts": 25, + "src/main/browser/browser-client-page-command-executor.test.ts": 33, + "src/main/browser/browser-client-page-command-integration.test.ts": 21, + "src/main/browser/browser-client-page-execution-host-supersession.test.ts": 12, + "src/main/browser/browser-client-page-inventory.test.ts": 68, + "src/main/browser/browser-client-page-metadata-transport.test.ts": 21, + "src/main/browser/browser-client-page-published-url.test.ts": 12, + "src/main/browser/browser-client-page-reconciliation-adapters.test.ts": 28, + "src/main/browser/browser-client-page-renderer-bridge.test.ts": 35, + "src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts": 1571, + "src/main/browser/browser-client-page-renderer-runtime.test.ts": 12, + "src/main/browser/browser-client-page-unavailability.test.ts": 13, + "src/main/browser/browser-client-page-upload-routing.test.ts": 60, + "src/main/browser/browser-client-route-cookie-import-partition-parity.test.ts": 20, + "src/main/browser/browser-client-route-cookie-import.test.ts": 14, + "src/main/browser/browser-client-upload-command.test.ts": 34, + "src/main/browser/browser-client-upload-staging.test.ts": 57, + "src/main/browser/browser-client-upload-transfer.test.ts": 3444, + "src/main/browser/browser-cookie-clear-preserve.test.ts": 71, + "src/main/browser/browser-cookie-clear-store-lifecycle.test.ts": 11, + "src/main/browser/browser-cookie-clear-store.test.ts": 25, + "src/main/browser/browser-cookie-import-app-bound-prefix.test.ts": 7, + "src/main/browser/browser-cookie-import-clear-atomicity.test.ts": 15, + "src/main/browser/browser-cookie-import-concurrency.test.ts": 113, + "src/main/browser/browser-cookie-import-google-exclusion.test.ts": 135, + "src/main/browser/browser-cookie-import-partition-fidelity.test.ts": 502, + "src/main/browser/browser-cookie-import-partition-reland.electron.test.ts": 2913, + "src/main/browser/browser-cookie-import-partition-rollback.electron.test.ts": 4236, + "src/main/browser/browser-cookie-import-partition-success.electron.test.ts": 513, + "src/main/browser/browser-cookie-import-partition.electron.test.ts": 606, + "src/main/browser/browser-cookie-import-plan-writes.test.ts": 19, + "src/main/browser/browser-cookie-import-policy.test.ts": 127, + "src/main/browser/browser-cookie-import-replace-partition-rollback.electron.test.ts": 1412, + "src/main/browser/browser-cookie-import-replacement.test.ts": 72, + "src/main/browser/browser-cookie-import-route-partition-staging.test.ts": 51, + "src/main/browser/browser-cookie-import-scope.test.ts": 72, + "src/main/browser/browser-cookie-import-undecryptable.test.ts": 285, + "src/main/browser/browser-cookie-import-validated-partition.electron.test.ts": 594, + "src/main/browser/browser-cookie-import-write.test.ts": 11, + "src/main/browser/browser-cookie-import.comet.test.ts": 427, + "src/main/browser/browser-cookie-import.helium.test.ts": 358, + "src/main/browser/browser-cookie-import.test.ts": 1644, + "src/main/browser/browser-cookie-registrable-family.test.ts": 19, + "src/main/browser/browser-cookie-samesite.electron.test.ts": 714, + "src/main/browser/browser-cookie-source-partition.test.ts": 8, + "src/main/browser/browser-cookie-validation.test.ts": 7, + "src/main/browser/browser-download-destination.test.ts": 15, + "src/main/browser/browser-execution-host-storage-identity.test.ts": 10, + "src/main/browser/browser-google-auth-ua.test.ts": 7, + "src/main/browser/browser-grab-payload.test.ts": 13, + "src/main/browser/browser-guest-shortcut-forwarding.test.ts": 31, + "src/main/browser/browser-guest-wheel-zoom-scroll.test.ts": 6, + "src/main/browser/browser-host-client-identity.test.ts": 30, + "src/main/browser/browser-host-lease-reconnect-delay.test.ts": 6, + "src/main/browser/browser-manager-annotation-bridge.test.ts": 16, + "src/main/browser/browser-manager-auth-user-agent.test.ts": 19, + "src/main/browser/browser-manager-client-hosted-downloads.test.ts": 135, + "src/main/browser/browser-manager-downloads.test.ts": 40, + "src/main/browser/browser-manager-grab-capture.test.ts": 13, + "src/main/browser/browser-manager-grab-mode.test.ts": 16, + "src/main/browser/browser-manager-grab-selection.test.ts": 37, + "src/main/browser/browser-manager-grab-shortcuts.test.ts": 14, + "src/main/browser/browser-manager-guest-lifecycle.test.ts": 33, + "src/main/browser/browser-manager-guest-policy-profile.test.ts": 11, + "src/main/browser/browser-manager-guest-shortcuts.test.ts": 26, + "src/main/browser/browser-manager-guest-visibility.test.ts": 16, + "src/main/browser/browser-manager-load-failure-replay.test.ts": 29, + "src/main/browser/browser-manager-popup-child.test.ts": 19, + "src/main/browser/browser-manager-popup-routing.test.ts": 77, + "src/main/browser/browser-manager-viewport-override.test.ts": 45, + "src/main/browser/browser-manager-viewport-partial-failure.test.ts": 13, + "src/main/browser/browser-network-deferred-socket.test.ts": 6, + "src/main/browser/browser-network-execution-route.test.ts": 14, + "src/main/browser/browser-network-tunnel-client-memory-budget.test.ts": 13, + "src/main/browser/browser-network-tunnel-client.test.ts": 36, + "src/main/browser/browser-network-tunnel-conformance.test.ts": 56, + "src/main/browser/browser-network-tunnel-outbound-memory-budget.test.ts": 7, + "src/main/browser/browser-network-tunnel-session-aggregate-memory.test.ts": 6, + "src/main/browser/browser-network-tunnel-session-stream-scoped-failure.test.ts": 26, + "src/main/browser/browser-network-tunnel-session.test.ts": 57, + "src/main/browser/browser-page-initiated-tab-budget.test.ts": 5, + "src/main/browser/browser-route-dns-prefetch.electron.test.ts": 4337, + "src/main/browser/browser-route-guest-popups.test.ts": 14, + "src/main/browser/browser-route-h3-egress.electron.test.ts": 13849, + "src/main/browser/browser-route-identity.test.ts": 8, + "src/main/browser/browser-route-partition-binding-capacity.test.ts": 27, + "src/main/browser/browser-route-partition-binding-store.test.ts": 42, + "src/main/browser/browser-route-partition-migration.test.ts": 261, + "src/main/browser/browser-route-partition-stability.test.ts": 30, + "src/main/browser/browser-route-partition-storage-lifecycle.test.ts": 33, + "src/main/browser/browser-route-partition-storage-retirement.test.ts": 14, + "src/main/browser/browser-route-partition-storage-runtime.test.ts": 10, + "src/main/browser/browser-route-persisted-worker-egress.electron.test.ts": 1471, + "src/main/browser/browser-route-prepared-page-rekey.test.ts": 9, + "src/main/browser/browser-route-session-registry.test.ts": 30, + "src/main/browser/browser-route-tcp-egress.electron.test.ts": 1082, + "src/main/browser/browser-route-webcontents-registry.test.ts": 43, + "src/main/browser/browser-route-webrtc-egress.electron.test.ts": 8203, + "src/main/browser/browser-screencast-lifecycle.test.ts": 16, + "src/main/browser/browser-screencast-snapshot-scaling.test.ts": 116, + "src/main/browser/browser-screencast-stream.test.ts": 549, + "src/main/browser/browser-session-cookie-staging.scoped.test.ts": 105, + "src/main/browser/browser-session-cookie-staging.test.ts": 8, + "src/main/browser/browser-session-partition-policies.test.ts": 36, + "src/main/browser/browser-session-partition-proxy-install.test.ts": 222, + "src/main/browser/browser-session-proxy.test.ts": 171, + "src/main/browser/browser-session-registry.persistence.test.ts": 550, + "src/main/browser/browser-session-registry.test.ts": 497, + "src/main/browser/browser-session-startup.test.ts": 33, + "src/main/browser/browser-session-ua-wire-identity.electron.test.ts": 2126, + "src/main/browser/browser-text-insertion.test.ts": 19, + "src/main/browser/browser-viewport-user-agent.test.ts": 9, + "src/main/browser/browser-webauthn-access.test.ts": 7, + "src/main/browser/browser-webauthn-account-picker.test.ts": 15, + "src/main/browser/browser-webauthn-profile-delete.test.ts": 64, + "src/main/browser/cdp-bridge-integration.test.ts": 3216, + "src/main/browser/cdp-bridge-state.test.ts": 26, + "src/main/browser/cdp-print-to-pdf.test.ts": 22, + "src/main/browser/cdp-screenshot.test.ts": 16, + "src/main/browser/cdp-ws-proxy-focus-replay.test.ts": 199, + "src/main/browser/cdp-ws-proxy.test.ts": 2405, + "src/main/browser/chromium-cookie-snapshot.test.ts": 279, + "src/main/browser/client-route-cookie-import-source-store.test.ts": 51, + "src/main/browser/doc-preview-download-block-notice.test.ts": 15, + "src/main/browser/doc-preview-failure-notice.test.ts": 7, + "src/main/browser/doc-preview-file-reader.test.ts": 31, + "src/main/browser/doc-preview-grant-registry.test.ts": 12, + "src/main/browser/doc-preview-guest-policy.test.ts": 46, + "src/main/browser/doc-preview-protocol.test.ts": 48, + "src/main/browser/electron-debugger-lease.test.ts": 4, + "src/main/browser/electron-probe-display-launch.test.ts": 8, + "src/main/browser/execution-route-socket-duplex.test.ts": 42, + "src/main/browser/grab-guest-script.test.ts": 29, + "src/main/browser/local-ssh-browser-partition-identity.test.ts": 11, + "src/main/browser/local-ssh-browser-partitions.probe-cache.test.ts": 7, + "src/main/browser/local-ssh-browser-route.test.ts": 40, + "src/main/browser/offscreen-browser-backend-lifecycle.test.ts": 67, + "src/main/browser/offscreen-browser-backend.web-preferences.test.ts": 7, + "src/main/browser/paired-runtime-browser-client-host-composition.test.ts": 51, + "src/main/browser/paired-runtime-browser-client-host-identity-wiring.test.ts": 732, + "src/main/browser/paired-runtime-browser-client-host-reconnect.test.ts": 274, + "src/main/browser/paired-runtime-browser-client-host-registry.test.ts": 13, + "src/main/browser/paired-runtime-browser-client-host-route-identity.test.ts": 60, + "src/main/browser/paired-runtime-browser-client-host-runtime.test.ts": 7, + "src/main/browser/paired-runtime-browser-client-host.test.ts": 95, + "src/main/browser/paired-runtime-browser-host-command-admission.test.ts": 24, + "src/main/browser/paired-runtime-browser-host-lease-reconnect.test.ts": 708, + "src/main/browser/paired-runtime-browser-host-lease.test.ts": 692, + "src/main/browser/paired-runtime-browser-host-reconciliation-negotiation.test.ts": 66, + "src/main/browser/paired-runtime-browser-network-route.test.ts": 1007, + "src/main/browser/popup-origin-bar-window.test.ts": 19, + "src/main/browser/remote-browser-socks-buffering.test.ts": 1148, + "src/main/browser/remote-browser-socks-server.test.ts": 296, + "src/main/browser/snapshot-engine.test.ts": 13, + "src/main/browser/ssh-browser-network-execution-route.test.ts": 175, + "src/main/browser/system-ssh-socks-client-socket.test.ts": 16, + "src/main/browser/wsl-browser-network-execution-route.test.ts": 128, + "src/main/browser/wsl-browser-network-relay-launch.test.ts": 23, + "src/main/claude-accounts/claude-account-service-account-selection.test.ts": 134, + "src/main/claude-accounts/claude-account-service-add-account.test.ts": 91, + "src/main/claude-accounts/claude-account-service-api-parity.test.ts": 65, + "src/main/claude-accounts/claude-account-service-config-dir-capture.test.ts": 157, + "src/main/claude-accounts/claude-account-service-credential-capture.test.ts": 94, + "src/main/claude-accounts/claude-account-service-login-process.test.ts": 480, + "src/main/claude-accounts/claude-account-service-reauth-rollback.test.ts": 193, + "src/main/claude-accounts/claude-duplicate-account.test.ts": 4, + "src/main/claude-accounts/claude-login-completion.oracle.test.ts": 105, + "src/main/claude-accounts/claude-structured-auth-policy.test.ts": 8, + "src/main/claude-accounts/claude-windows-interactive-login.test.ts": 75, + "src/main/claude-accounts/keychain.test.ts": 13, + "src/main/claude-accounts/live-pty-gate.test.ts": 13, + "src/main/claude-accounts/oauth-refresh.test.ts": 20, + "src/main/claude-accounts/runtime-auth-service-account-switching.test.ts": 147, + "src/main/claude-accounts/runtime-auth-service-deselect-restore.test.ts": 284, + "src/main/claude-accounts/runtime-auth-service-keychain-snapshots.test.ts": 431, + "src/main/claude-accounts/runtime-auth-service-launch-refresh.test.ts": 159, + "src/main/claude-accounts/runtime-auth-service-materialization.test.ts": 226, + "src/main/claude-accounts/runtime-auth-service-readback-identity.test.ts": 298, + "src/main/claude-accounts/runtime-auth-service-snapshot-validation.test.ts": 319, + "src/main/claude-accounts/runtime-auth-service-wsl-runtime.test.ts": 197, + "src/main/claude-accounts/runtime-selection.test.ts": 4, + "src/main/claude-accounts/windows-command-invocation.test.ts": 8, + "src/main/claude-usage/claude-model-pricing.test.ts": 12, + "src/main/claude-usage/claude-usage-report-aggregation.test.ts": 6, + "src/main/claude-usage/scanner-large-directory.test.ts": 530, + "src/main/claude-usage/scanner-scan.test.ts": 114, + "src/main/claude-usage/scanner.test.ts": 31, + "src/main/claude-usage/store.test.ts": 35, + "src/main/claude-usage/transcript-record-parser-prefilter.test.ts": 5, + "src/main/claude-usage/worktree-attribution-scaling.test.ts": 13, + "src/main/claude/claude-agent-sdk-contract-pins.test.ts": 2683, + "src/main/claude/claude-agent-sdk-control-requests.test.ts": 10, + "src/main/claude/claude-agent-sdk-exit-proof-identity.test.ts": 7, + "src/main/claude/claude-agent-sdk-exit-proof.test.ts": 12842, + "src/main/claude/claude-agent-sdk-import-boundary.test.ts": 951, + "src/main/claude/claude-agent-sdk-process-spawn.test.ts": 13, + "src/main/claude/claude-agent-sdk-root-kill-fallback.test.ts": 15, + "src/main/claude/claude-agent-sdk-user-message-queue.test.ts": 109, + "src/main/claude/claude-background-task-resume.test.ts": 14, + "src/main/claude/claude-background-task-tracker.test.ts": 93, + "src/main/claude/claude-child-process-environment.test.ts": 4, + "src/main/claude/claude-command-lifecycle-frames.test.ts": 7, + "src/main/claude/claude-config-dir-pin.test.ts": 6, + "src/main/claude/claude-descendant-escalation-boundary.test.ts": 462, + "src/main/claude/claude-slash-command-catalog.test.ts": 10, + "src/main/claude/claude-stream-json-connection-close.test.ts": 140, + "src/main/claude/claude-stream-json-connection.test.ts": 22607, + "src/main/claude/claude-streamed-text-checkpoints.test.ts": 9, + "src/main/claude/claude-structured-auth-parity.test.ts": 100, + "src/main/claude/claude-structured-compaction.test.ts": 10, + "src/main/claude/claude-structured-content-parts.test.ts": 18, + "src/main/claude/claude-structured-control-actions.test.ts": 12, + "src/main/claude/claude-structured-dispatch-admission.test.ts": 15, + "src/main/claude/claude-structured-dispatch-content.test.ts": 10, + "src/main/claude/claude-structured-dispatch.test.ts": 1411, + "src/main/claude/claude-structured-effort-reporting.test.ts": 27, + "src/main/claude/claude-structured-inbound-control.test.ts": 15, + "src/main/claude/claude-structured-journal-translation-subagents.test.ts": 23, + "src/main/claude/claude-structured-journal-translation-turn-timing.test.ts": 22, + "src/main/claude/claude-structured-journal-translation.test.ts": 63, + "src/main/claude/claude-structured-launch-resolution.test.ts": 28, + "src/main/claude/claude-structured-location-support.test.ts": 6, + "src/main/claude/claude-structured-model-confirmation.test.ts": 18, + "src/main/claude/claude-structured-option-confirmation.test.ts": 16, + "src/main/claude/claude-structured-options.test.ts": 8, + "src/main/claude/claude-structured-owner-identity.test.ts": 10, + "src/main/claude/claude-structured-prompt-items.test.ts": 11, + "src/main/claude/claude-structured-provider-fallback.test.ts": 25, + "src/main/claude/claude-structured-rewind.test.ts": 25, + "src/main/claude/claude-structured-session-acquisition-processless.test.ts": 8, + "src/main/claude/claude-structured-session-adapter-turns.test.ts": 25, + "src/main/claude/claude-structured-session-adapter.test.ts": 63, + "src/main/claude/claude-structured-session-close.test.ts": 26, + "src/main/claude/claude-structured-session-commands.test.ts": 10, + "src/main/claude/claude-structured-session-recovery.test.ts": 47, + "src/main/claude/claude-subagent-group-row.test.ts": 6, + "src/main/claude/claude-subagent-id-aliases.test.ts": 7, + "src/main/claude/claude-subagent-roster.test.ts": 22, + "src/main/claude/claude-subagent-task-frames.test.ts": 12, + "src/main/claude/claude-transcript-rewind-proof.test.ts": 10, + "src/main/claude/claude-tui-exit.test.ts": 18, + "src/main/claude/claude-tui-resume-launch.test.ts": 10, + "src/main/claude/claude-tui-resume-proof.test.ts": 9, + "src/main/claude/compact-status-registration.test.ts": 27, + "src/main/claude/hook-service.test.ts": 58, + "src/main/claude/statusline-script.test.ts": 186, + "src/main/cli/appimage-extracted-root.test.ts": 148, + "src/main/cli/appimage-extraction-pruning.test.ts": 37, + "src/main/cli/appimage-payload-removal.reentrancy.test.ts": 12, + "src/main/cli/appimage-payload-removal.test.ts": 13, + "src/main/cli/appimage-registration-lock.test.ts": 31, + "src/main/cli/appimage-stable-launcher.test.ts": 158, + "src/main/cli/cli-command-installation-races.test.ts": 78, + "src/main/cli/cli-installer-appimage-ownership.test.ts": 104, + "src/main/cli/cli-installer-appimage-removal.test.ts": 93, + "src/main/cli/cli-installer-command-conflicts.test.ts": 65, + "src/main/cli/cli-installer-macos-command-path.test.ts": 84, + "src/main/cli/cli-installer-windows-path.test.ts": 59, + "src/main/cli/cli-installer.test.ts": 153, + "src/main/cli/cli-privileged-processes.test.ts": 10, + "src/main/cli/keyed-promise-queue.test.ts": 8, + "src/main/cli/legacy-appimage-cli-wrapper.test.ts": 5, + "src/main/cli/linux-bare-orca-dispatcher.test.ts": 51, + "src/main/cli/linux-terminal-orca-cli-shim.test.ts": 75, + "src/main/cli/orca-cli-child-path.test.ts": 14, + "src/main/cli/packaged-cli-assets.test.ts": 492, + "src/main/cli/windows-launcher-asset.test.ts": 7, + "src/main/cli/windows-user-path-registry.test.ts": 10, + "src/main/cli/wsl-cli-installer.test.ts": 52, + "src/main/cli/wsl-cli-powershell-boundary.test.ts": 6, + "src/main/cli/wsl-cli-registration-operation.test.ts": 63, + "src/main/cli/wsl-cli-registration-reconciliation.test.ts": 13, + "src/main/cli/wsl-cli-registration-registry.test.ts": 84, + "src/main/codex-accounts/codex-account-identity-api-key-guard.test.ts": 7, + "src/main/codex-accounts/codex-auth-workspace-identity.test.ts": 15, + "src/main/codex-accounts/codex-credential-absence-grace.test.ts": 9, + "src/main/codex-accounts/codex-windows-interactive-login.test.ts": 234, + "src/main/codex-accounts/fs-utils.test.ts": 69, + "src/main/codex-accounts/host-codex-managed-home-ownership.test.ts": 12, + "src/main/codex-accounts/legacy-shared-auth-migration.test.ts": 62, + "src/main/codex-accounts/legacy-shared-config-compatibility.test.ts": 20, + "src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts": 11759, + "src/main/codex-accounts/legacy-wsl-runtime-auth-drain-rollback-script.test.ts": 400, + "src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts": 23, + "src/main/codex-accounts/managed-codex-auth-readiness.test.ts": 36, + "src/main/codex-accounts/runtime-home-legacy-migration.test.ts": 381, + "src/main/codex-accounts/runtime-home-managed-auth-recovery.test.ts": 217, + "src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts": 163, + "src/main/codex-accounts/runtime-home-per-account-homes.test.ts": 440, + "src/main/codex-accounts/runtime-home-real-home-lane-routing.test.ts": 278, + "src/main/codex-accounts/runtime-home-resume-selection-gate.test.ts": 119, + "src/main/codex-accounts/runtime-home-retained-auth-provenance.test.ts": 657, + "src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts": 278, + "src/main/codex-accounts/runtime-home-session-migration-pass.test.ts": 282, + "src/main/codex-accounts/runtime-home-system-default-mirror-readback.test.ts": 709, + "src/main/codex-accounts/runtime-home-system-default-snapshot.test.ts": 618, + "src/main/codex-accounts/runtime-home-system-resource-materialization.test.ts": 216, + "src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts": 3, + "src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts": 742, + "src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts": 393, + "src/main/codex-accounts/runtime-home-wsl-system-default.test.ts": 408, + "src/main/codex-accounts/runtime-selection.test.ts": 6, + "src/main/codex-accounts/service-account-add-login.test.ts": 289, + "src/main/codex-accounts/service-account-selection-and-removal.test.ts": 614, + "src/main/codex-accounts/service-add-account-from-home.test.ts": 177, + "src/main/codex-accounts/service-login-process-teardown.test.ts": 166, + "src/main/codex-accounts/service-managed-home-removal-retries.test.ts": 165, + "src/main/codex-accounts/service-quota-refresh-decoupling.test.ts": 174, + "src/main/codex-accounts/service-reauthenticate-activation.test.ts": 129, + "src/main/codex-accounts/service-reset-credit-durability.test.ts": 449, + "src/main/codex-accounts/service-reset-credit-home-ownership.test.ts": 175, + "src/main/codex-accounts/service-reset-credit-target-routing.test.ts": 339, + "src/main/codex-accounts/service-system-default-identity.test.ts": 207, + "src/main/codex-accounts/service-wsl-accounts.test.ts": 298, + "src/main/codex-accounts/service.test.ts": 181, + "src/main/codex-accounts/sta-4422-transient-ownership-error.test.ts": 209, + "src/main/codex-accounts/sta-4734-login-rollback-deletes-authenticated-home.test.ts": 376, + "src/main/codex-accounts/sta-4735-unreadable-host-lane-writes.test.ts": 145, + "src/main/codex-accounts/wsl-codex-command.test.ts": 8, + "src/main/codex-cli/codex-home-process-lock.test.ts": 15, + "src/main/codex-cli/command.test.ts": 39, + "src/main/codex-usage/scanner-large-directory.test.ts": 1093, + "src/main/codex-usage/scanner-paths.test.ts": 64, + "src/main/codex-usage/scanner.test.ts": 10, + "src/main/codex-usage/store-automation-usage.test.ts": 12, + "src/main/codex-usage/store-model-pricing.test.ts": 17, + "src/main/codex-usage/store-orca-scope.test.ts": 17, + "src/main/codex-usage/store-persistence.test.ts": 20, + "src/main/codex-usage/store-snapshot.benchmark.test.ts": 152, + "src/main/codex/codex-account-session-bridge.test.ts": 47, + "src/main/codex/codex-ai-vault-session-resume.test.ts": 14, + "src/main/codex/codex-app-server-capability-cache.test.ts": 11, + "src/main/codex/codex-app-server-client.test.ts": 1418, + "src/main/codex/codex-app-server-connection.test.ts": 5017, + "src/main/codex/codex-app-server-posix-supervisor.test.ts": 9, + "src/main/codex/codex-app-server-process-teardown.test.ts": 26, + "src/main/codex/codex-app-server-session.test.ts": 116, + "src/main/codex/codex-app-server-teardown.integration.test.ts": 17565, + "src/main/codex/codex-background-command-tracker.test.ts": 19, + "src/main/codex/codex-background-task-tracker.test.ts": 38, + "src/main/codex/codex-config-mirror.test.ts": 96, + "src/main/codex/codex-config-settings-removal.test.ts": 5, + "src/main/codex/codex-goal-journal-rows.test.ts": 14, + "src/main/codex/codex-home-paths.test.ts": 31, + "src/main/codex/codex-hook-legacy-profile-block.test.ts": 8, + "src/main/codex/codex-hook-trust-grant.test.ts": 241, + "src/main/codex/codex-hooks-read-denial-followup.test.ts": 116, + "src/main/codex/codex-legacy-session-resume.test.ts": 69, + "src/main/codex/codex-model-provider-config.test.ts": 9, + "src/main/codex/codex-notice-item-translation.test.ts": 13, + "src/main/codex/codex-pane-account-registry-mutations.test.ts": 14, + "src/main/codex/codex-pane-launch-account.test.ts": 8, + "src/main/codex/codex-persistent-command-retention.test.ts": 226, + "src/main/codex/codex-real-home-hook-install.test.ts": 70, + "src/main/codex/codex-real-home-path.test.ts": 7, + "src/main/codex/codex-requested-close-turn-timing.test.ts": 15, + "src/main/codex/codex-resume-process-proof.test.ts": 20, + "src/main/codex/codex-rollout-session-meta.test.ts": 21, + "src/main/codex/codex-server-request-disposition.test.ts": 21, + "src/main/codex/codex-session-backfill-marker.test.ts": 29, + "src/main/codex/codex-session-backfill-scan-dates.test.ts": 27, + "src/main/codex/codex-session-backfill.test.ts": 408, + "src/main/codex/codex-session-bridge.test.ts": 66, + "src/main/codex/codex-session-index-heal-state.test.ts": 369, + "src/main/codex/codex-session-index-heal.test.ts": 1379, + "src/main/codex/codex-session-migration-recent-exits.test.ts": 13, + "src/main/codex/codex-session-migration-scheduler.test.ts": 369, + "src/main/codex/codex-session-resume-home.test.ts": 22, + "src/main/codex/codex-session-resume-preparation.test.ts": 13, + "src/main/codex/codex-session-resume-wrong-account.test.ts": 24, + "src/main/codex/codex-session-source-home.test.ts": 5, + "src/main/codex/codex-stale-pane-accounts.test.ts": 95, + "src/main/codex/codex-state-db-backfill-recovery.test.ts": 147, + "src/main/codex/codex-state-db.test.ts": 27, + "src/main/codex/codex-structured-acquisition-exit-proof.test.ts": 11, + "src/main/codex/codex-structured-app-server-args.test.ts": 7, + "src/main/codex/codex-structured-child-environment.test.ts": 8, + "src/main/codex/codex-structured-item-translation.test.ts": 50, + "src/main/codex/codex-structured-journal-compactions.test.ts": 16, + "src/main/codex/codex-structured-journal-goal-admission.test.ts": 33, + "src/main/codex/codex-structured-journal-goal-resume.test.ts": 125, + "src/main/codex/codex-structured-journal-goal-rows.test.ts": 16, + "src/main/codex/codex-structured-journal-translation-settlement.test.ts": 843, + "src/main/codex/codex-structured-journal-translation-streams.test.ts": 85, + "src/main/codex/codex-structured-journal-translation-subagents.test.ts": 26, + "src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts": 60, + "src/main/codex/codex-structured-journal-translation-turn-state.test.ts": 12, + "src/main/codex/codex-structured-journal-translation.test.ts": 96, + "src/main/codex/codex-structured-launch-resolution.test.ts": 17, + "src/main/codex/codex-structured-location-support.test.ts": 3, + "src/main/codex/codex-structured-owner-identity.test.ts": 9, + "src/main/codex/codex-structured-prompt-items.test.ts": 39, + "src/main/codex/codex-structured-prompt-replies.test.ts": 40, + "src/main/codex/codex-structured-rewind.test.ts": 34, + "src/main/codex/codex-structured-session-adapter-lifecycle.test.ts": 19, + "src/main/codex/codex-structured-session-adapter.test.ts": 37, + "src/main/codex/codex-structured-session-background-tasks.test.ts": 76, + "src/main/codex/codex-structured-session-cancel.test.ts": 164, + "src/main/codex/codex-structured-session-close.test.ts": 20, + "src/main/codex/codex-structured-session-options.test.ts": 12, + "src/main/codex/codex-structured-session-shutdown.test.ts": 62, + "src/main/codex/codex-structured-thread-open.test.ts": 19, + "src/main/codex/codex-structured-turn-processes.integration.test.ts": 85, + "src/main/codex/codex-subagent-execution-projection.test.ts": 16, + "src/main/codex/codex-subagent-executions.test.ts": 21, + "src/main/codex/codex-subagent-roster.test.ts": 40, + "src/main/codex/codex-tool-identity-translation.test.ts": 14, + "src/main/codex/codex-trust-config-concurrent-launch.test.ts": 70, + "src/main/codex/codex-trust-config-mutation-queue.test.ts": 10, + "src/main/codex/codex-trust-config-rollback.test.ts": 16, + "src/main/codex/codex-trust-grant-host.test.ts": 12, + "src/main/codex/codex-trust-grant-ledger.test.ts": 8, + "src/main/codex/codex-trust-grant-main-thread-boundary.test.ts": 15, + "src/main/codex/codex-trust-grant-telemetry.test.ts": 8, + "src/main/codex/codex-tui-rollout-proof.test.ts": 33, + "src/main/codex/codex-turn-ordinals.test.ts": 45, + "src/main/codex/codex-user-hook-trust-rebase-client.test.ts": 13, + "src/main/codex/codex-user-hook-trust-rebase.test.ts": 17, + "src/main/codex/codex-wsl-hook-install-plan.test.ts": 11, + "src/main/codex/config-plugin-registration-promotion.test.ts": 193, + "src/main/codex/config-settings-baseline-upgrade.test.ts": 61, + "src/main/codex/config-settings-promotion.test.ts": 227, + "src/main/codex/config-sync-stall.test.ts": 47, + "src/main/codex/config-toml-hook-trust-scaling.test.ts": 38, + "src/main/codex/config-toml-trust-api-parity.test.ts": 13, + "src/main/codex/config-toml-trust-hash.test.ts": 7, + "src/main/codex/config-toml-trust-hook-read.test.ts": 16, + "src/main/codex/config-toml-trust-hook-removal.test.ts": 26, + "src/main/codex/config-toml-trust-hook-upsert.test.ts": 44, + "src/main/codex/config-toml-trust-key.test.ts": 10, + "src/main/codex/config-toml-trust-paths.test.ts": 10, + "src/main/codex/config-toml-trust-project.test.ts": 27, + "src/main/codex/hook-service-concurrent-launch-install.test.ts": 618, + "src/main/codex/hook-service-legacy-cleanup.test.ts": 601, + "src/main/codex/hook-service-managed-install.test.ts": 99, + "src/main/codex/hook-service-runtime-trust-repair.test.ts": 117, + "src/main/codex/hook-service-trust-grant.test.ts": 123, + "src/main/codex/hook-service-user-hook-mirroring.test.ts": 186, + "src/main/codex/hook-service-wsl-runtime.test.ts": 113, + "src/main/codex/hook-trust-promotion.test.ts": 225, + "src/main/codex/managed-home-shell-preflight.test.ts": 28, + "src/main/codex/retained-codex-hook-state.test.ts": 8, + "src/main/codex/sta-4735-hook-trust-provenance-overwrite.test.ts": 12, + "src/main/codex/sta-4737-unreadable-is-not-absent.test.ts": 58, + "src/main/codex/sta-4823-shared-state-survives-a-failed-read.test.ts": 94, + "src/main/codex/wsl-codex-session-bridge.test.ts": 82, + "src/main/command-code/hook-service.test.ts": 239, + "src/main/computer/computer-action-verification-normalization.test.ts": 4, + "src/main/computer/computer-provider-lifecycle.test.ts": 5, + "src/main/computer/computer-provider-unavailable-message.test.ts": 3, + "src/main/computer/computer-sidecar-diagnostics.test.ts": 8, + "src/main/computer/desktop-script-provider-action-errors.test.ts": 68, + "src/main/computer/desktop-script-provider-actions.test.ts": 131, + "src/main/computer/desktop-script-provider-bridge.test.ts": 6, + "src/main/computer/desktop-script-provider-cache-lifecycle.test.ts": 96, + "src/main/computer/desktop-script-provider-cache.test.ts": 115, + "src/main/computer/desktop-script-provider-client.test.ts": 154, + "src/main/computer/desktop-script-provider-errors.test.ts": 161, + "src/main/computer/desktop-script-provider-paste-validation.test.ts": 105, + "src/main/computer/desktop-script-provider-runtime-host-routing.test.ts": 44, + "src/main/computer/desktop-script-runtime-host.test.ts": 106, + "src/main/computer/desktop-script-serve-channel.test.ts": 9, + "src/main/computer/macos-computer-use-permission-status.test.ts": 38, + "src/main/computer/macos-computer-use-permissions.test.ts": 24, + "src/main/computer/macos-native-provider-client.test.ts": 557, + "src/main/computer/macos-native-provider-paste-validation.test.ts": 99, + "src/main/computer/macos-native-provider-socket.test.ts": 14, + "src/main/computer/sidecar-client.test.ts": 28, + "src/main/computer/windows-powershell-execution-policy.test.ts": 7, + "src/main/copilot/hook-service.test.ts": 83, + "src/main/crash-reporting/crash-breadcrumb-renderer-attribution.test.ts": 29, + "src/main/crash-reporting/crash-breadcrumb-store-leak.test.ts": 60, + "src/main/crash-reporting/crash-breadcrumb-store-orphan-cleanup.test.ts": 45, + "src/main/crash-reporting/crash-breadcrumb-store.test.ts": 34, + "src/main/crash-reporting/crash-report-copy-text.test.ts": 8, + "src/main/crash-reporting/crash-report-store.test.ts": 351, + "src/main/crash-reporting/crashpad-capture.test.ts": 60, + "src/main/crash-reporting/durable-crash-breadcrumb.test.ts": 11, + "src/main/crash-reporting/expected-teardown-state.test.ts": 9, + "src/main/crash-reporting/gpu-crash-diagnostics.test.ts": 14, + "src/main/crash-reporting/gpu-crash-fallback-decision.test.ts": 13, + "src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts": 9, + "src/main/crash-reporting/gpu-fallback-engagement.test.ts": 14, + "src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts": 10, + "src/main/crash-reporting/gpu-fallback-restart-prompt.test.ts": 8, + "src/main/crash-reporting/main-process-lifecycle-identity.test.ts": 6, + "src/main/crash-reporting/minidump-crash-signature.test.ts": 30, + "src/main/crash-reporting/pre-gone-host-memory.test.ts": 18, + "src/main/crash-reporting/process-gone-classification.test.ts": 12, + "src/main/crash-reporting/process-gone-dedupe.test.ts": 6, + "src/main/crash-reporting/process-gone-diagnostics.test.ts": 19, + "src/main/crash-reporting/process-gone-killed-one-ordering.test.ts": 40, + "src/main/crash-reporting/process-gone-recorder.test.ts": 599, + "src/main/crash-reporting/process-gone-sibling-correlation.test.ts": 514, + "src/main/crash-reporting/renderer-recovery-circuit-breaker.test.ts": 10, + "src/main/crash-reporting/self-initiated-tree-kill-log.test.ts": 26, + "src/main/crash-reporting/suppressed-process-gone-breadcrumb.test.ts": 5, + "src/main/cursor/hook-service.test.ts": 173, + "src/main/daemon/agent-startup-prompt-latency.node-pty.test.ts": 1916, + "src/main/daemon/bash-prompt-command-composition.test.ts": 3541, + "src/main/daemon/client.test.ts": 392, + "src/main/daemon/cold-restore-payload-cache.test.ts": 9, + "src/main/daemon/daemon-adoption-telemetry-event.test.ts": 18, + "src/main/daemon/daemon-audit-eligibility-event.test.ts": 30, + "src/main/daemon/daemon-authenticated-client-activity.test.ts": 16, + "src/main/daemon/daemon-background-transient-facts.test.ts": 12, + "src/main/daemon/daemon-bundle-staleness.test.ts": 347, + "src/main/daemon/daemon-checkpoint-session-queue.test.ts": 204, + "src/main/daemon/daemon-client-notify-settlement.test.ts": 10, + "src/main/daemon/daemon-client-rpc-request.test.ts": 20, + "src/main/daemon/daemon-durable-history-ownership-seed.test.ts": 25, + "src/main/daemon/daemon-endpoint-ownership.test.ts": 26, + "src/main/daemon/daemon-endpoint-publish.test.ts": 92, + "src/main/daemon/daemon-endpoint-windows.test.ts": 8, + "src/main/daemon/daemon-entry-path-layouts.test.ts": 319, + "src/main/daemon/daemon-entry.test.ts": 15, + "src/main/daemon/daemon-errors.test.ts": 7, + "src/main/daemon/daemon-file-log.test.ts": 25, + "src/main/daemon/daemon-foreground-confirmation-protocol.test.ts": 7, + "src/main/daemon/daemon-health-endpoint-entry.test.ts": 9, + "src/main/daemon/daemon-health-socket-cleanup.test.ts": 16, + "src/main/daemon/daemon-health.test.ts": 3195, + "src/main/daemon/daemon-host-relocation.test.ts": 140, + "src/main/daemon/daemon-idle-shutdown.test.ts": 204, + "src/main/daemon/daemon-incarnation-evidence-main-thread.test.ts": 13, + "src/main/daemon/daemon-incarnation-evidence.test.ts": 23, + "src/main/daemon/daemon-init-child-readiness.test.ts": 406, + "src/main/daemon/daemon-init-child-startup-failure.test.ts": 406, + "src/main/daemon/daemon-init-endpoint-adoption.test.ts": 868, + "src/main/daemon/daemon-init-live-session-preservation.test.ts": 541, + "src/main/daemon/daemon-init-packaged-bundle-staleness.test.ts": 353, + "src/main/daemon/daemon-init-provider-installation.test.ts": 398, + "src/main/daemon/daemon-init-replacement-reporting.test.ts": 247, + "src/main/daemon/daemon-init-restart-sequence.test.ts": 576, + "src/main/daemon/daemon-init-wedged-daemon-grace.test.ts": 284, + "src/main/daemon/daemon-lifecycle-event.test.ts": 8, + "src/main/daemon/daemon-main.test.ts": 43, + "src/main/daemon/daemon-native-pty-exception.test.ts": 12, + "src/main/daemon/daemon-pid-file-parse.test.ts": 9, + "src/main/daemon/daemon-preflight-client-replacement.test.ts": 234, + "src/main/daemon/daemon-process-inspection.test.ts": 22, + "src/main/daemon/daemon-protocol-version.test.ts": 9, + "src/main/daemon/daemon-pty-adapter-cold-restore-reanchor.test.ts": 489, + "src/main/daemon/daemon-pty-adapter-cold-restore-seed.test.ts": 297, + "src/main/daemon/daemon-pty-adapter-concurrent-recovery.test.ts": 142, + "src/main/daemon/daemon-pty-adapter-daemon-recovery.test.ts": 505, + "src/main/daemon/daemon-pty-adapter-history-checkpoints.test.ts": 775, + "src/main/daemon/daemon-pty-adapter-history-recovery.test.ts": 580, + "src/main/daemon/daemon-pty-adapter-inventory-respawn.test.ts": 87, + "src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts": 73, + "src/main/daemon/daemon-pty-adapter-replacement-exit-race.test.ts": 53, + "src/main/daemon/daemon-pty-adapter-session-adoption.test.ts": 557, + "src/main/daemon/daemon-pty-adapter-steady-state-compat.test.ts": 7, + "src/main/daemon/daemon-pty-adapter.test.ts": 873, + "src/main/daemon/daemon-pty-router-history-handoff.test.ts": 575, + "src/main/daemon/daemon-pty-router.test.ts": 445, + "src/main/daemon/daemon-pty-startup-delivery.test.ts": 1989, + "src/main/daemon/daemon-pty-upgrade-adoption.test.ts": 120, + "src/main/daemon/daemon-pty-write-settlement-recovery.test.ts": 11, + "src/main/daemon/daemon-ready-identity.test.ts": 8, + "src/main/daemon/daemon-reattach-checkpoint-isolation.test.ts": 10265, + "src/main/daemon/daemon-respawn-throttle.test.ts": 9, + "src/main/daemon/daemon-restore-scrollback-depth.test.ts": 2078, + "src/main/daemon/daemon-self-retirement-respawn.test.ts": 148, + "src/main/daemon/daemon-server-async-spawn-cancellation.test.ts": 354, + "src/main/daemon/daemon-server-attach-only.test.ts": 59, + "src/main/daemon/daemon-server-attachment-lifecycle.test.ts": 214, + "src/main/daemon/daemon-server-error-handling.test.ts": 179, + "src/main/daemon/daemon-server-kill-attribution.test.ts": 20, + "src/main/daemon/daemon-server.test.ts": 702, + "src/main/daemon/daemon-session-owner-resolution.test.ts": 22, + "src/main/daemon/daemon-session-scrollback-window.test.ts": 54, + "src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts": 1702, + "src/main/daemon/daemon-spawner.test.ts": 74, + "src/main/daemon/daemon-stream-data-batcher.test.ts": 108, + "src/main/daemon/daemon-stream-droppability-lifecycle.test.ts": 46, + "src/main/daemon/daemon-stream-droppable-membership.test.ts": 40, + "src/main/daemon/daemon-tcc-attribution-main-thread.test.ts": 14, + "src/main/daemon/daemon-tcc-attribution.test.ts": 8, + "src/main/daemon/daemon-transport-attachment-release.test.ts": 114, + "src/main/daemon/degraded-daemon-pty-provider.test.ts": 58, + "src/main/daemon/hangul-cell-width-agreement.test.ts": 37, + "src/main/daemon/headless-emulator-fidelity.fuzz.test.ts": 17026, + "src/main/daemon/headless-emulator-restored-osc-links.test.ts": 50, + "src/main/daemon/headless-emulator-unicode-width.test.ts": 15, + "src/main/daemon/headless-emulator-wide-char-repaint.test.ts": 1773, + "src/main/daemon/headless-emulator-wide-char-snapshot.test.ts": 186, + "src/main/daemon/headless-emulator.test.ts": 114, + "src/main/daemon/headless-osc-link-ranges.test.ts": 119, + "src/main/daemon/hibernation-cold-restore-repro.test.ts": 73, + "src/main/daemon/history-manager-disabled-sessions-leak.test.ts": 58, + "src/main/daemon/history-manager.test.ts": 326, + "src/main/daemon/history-reader-memory.test.ts": 332, + "src/main/daemon/history-reader.test.ts": 85, + "src/main/daemon/issue-6814-daemon-failure-classification.test.ts": 6043, + "src/main/daemon/macos-login-session-death-watch.test.ts": 13, + "src/main/daemon/ndjson.test.ts": 173, + "src/main/daemon/node-pty-error-hints.test.ts": 6, + "src/main/daemon/osc7-file-uri.test.ts": 8, + "src/main/daemon/osc7-uri-extraction.test.ts": 8, + "src/main/daemon/post-ready-flush-gate.test.ts": 16, + "src/main/daemon/pty-session-id.test.ts": 16, + "src/main/daemon/pty-subprocess-cwd-cancel-identity.test.ts": 12, + "src/main/daemon/pty-subprocess-env-inheritance.test.ts": 58, + "src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts": 64, + "src/main/daemon/pty-subprocess-foreground-identity.test.ts": 303, + "src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts": 42, + "src/main/daemon/pty-subprocess-git-credential-guard.test.ts": 32, + "src/main/daemon/pty-subprocess-handle-lifecycle.test.ts": 138, + "src/main/daemon/pty-subprocess-io-failure-cleanup.test.ts": 3575, + "src/main/daemon/pty-subprocess-io-failure-native.test.ts": 2227, + "src/main/daemon/pty-subprocess-managed-agent-env.test.ts": 81, + "src/main/daemon/pty-subprocess-windows-shell-launch.test.ts": 66, + "src/main/daemon/pty-subprocess-wsl-launch.test.ts": 52, + "src/main/daemon/pty-subprocess.test.ts": 58, + "src/main/daemon/reattach-snapshot.test.ts": 649, + "src/main/daemon/repro-12101-mouse-tracking-survives-agent-death.test.ts": 61, + "src/main/daemon/repro-7329-remote-snapshot-corruption.test.ts": 57, + "src/main/daemon/session-pending-output.test.ts": 145, + "src/main/daemon/session-shell-recovery.test.ts": 122, + "src/main/daemon/session-terminal-control.test.ts": 46, + "src/main/daemon/session.test.ts": 279, + "src/main/daemon/shell-ready-bash-wrapper.test.ts": 338, + "src/main/daemon/slow-daemon-session-verification.test.ts": 13535, + "src/main/daemon/startup-device-attributes-responder.test.ts": 14, + "src/main/daemon/terminal-checkpoint-serializer.test.ts": 37, + "src/main/daemon/terminal-checkpoint-writer-bounds.test.ts": 91, + "src/main/daemon/terminal-cursor-line-context.test.ts": 26, + "src/main/daemon/terminal-history-incremental-restore.test.ts": 316, + "src/main/daemon/terminal-history-large-checkpoint-cold-restore.test.ts": 9348, + "src/main/daemon/terminal-history-log.test.ts": 13, + "src/main/daemon/terminal-history-permissions.test.ts": 87, + "src/main/daemon/terminal-history-restorable-retention.test.ts": 7, + "src/main/daemon/terminal-history-seed-chunks.test.ts": 7, + "src/main/daemon/terminal-history-seed-segments.test.ts": 22, + "src/main/daemon/terminal-history-seed-transfer-registry.test.ts": 8, + "src/main/daemon/terminal-history-session-tombstone-retry.test.ts": 10, + "src/main/daemon/terminal-host-agent-session.test.ts": 36, + "src/main/daemon/terminal-host-attach-only.test.ts": 28, + "src/main/daemon/terminal-host-cheap-tier-ps-scan-volume.test.ts": 59, + "src/main/daemon/terminal-host-concurrent-create.test.ts": 53, + "src/main/daemon/terminal-host-cwd-readability.test.ts": 30, + "src/main/daemon/terminal-host-non-agent-foreground.test.ts": 17, + "src/main/daemon/terminal-host-process-inspection-cheap-tier.test.ts": 29, + "src/main/daemon/terminal-host-process-inspection.test.ts": 18, + "src/main/daemon/terminal-host-pty-owner-backend.test.ts": 19, + "src/main/daemon/terminal-host-readiness-reporting.test.ts": 23, + "src/main/daemon/terminal-host-session-reaping-leak.test.ts": 30, + "src/main/daemon/terminal-host-startup.test.ts": 45, + "src/main/daemon/terminal-host-teardown-recreate.test.ts": 185, + "src/main/daemon/terminal-host-wsl-context.test.ts": 45, + "src/main/daemon/terminal-host.test.ts": 152, + "src/main/daemon/terminal-session-teardown.test.ts": 16, + "src/main/daemon/terminal-shell-lifecycle-scanner.test.ts": 21, + "src/main/daemon/terminal-shell-recovery-barrier.test.ts": 703, + "src/main/daemon/terminal-shell-recovery-clean-exit-retirement.test.ts": 26, + "src/main/daemon/terminal-snapshot-color-parity.test.ts": 109, + "src/main/daemon/terminal-snapshot-osc8-roundtrip.test.ts": 57, + "src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts": 130, + "src/main/daemon/windows-conpty-warmup.test.ts": 12, + "src/main/daemon/wsl-cold-restore-cwd.test.ts": 7, + "src/main/destination-serialized-local-rename.test.ts": 330, + "src/main/devin/hook-config-json.test.ts": 11, + "src/main/devin/hook-service.test.ts": 43, + "src/main/diagnostics/main-thread-churn-probe.test.ts": 13, + "src/main/dock/unread-badge.test.ts": 9, + "src/main/droid/hook-service.test.ts": 81, + "src/main/durable-file-write-syscall-proof.test.ts": 125, + "src/main/durable-file-write.test.ts": 89, + "src/main/emulator/android/adb-devices.test.ts": 10, + "src/main/emulator/android/android-app-control.test.ts": 7, + "src/main/emulator/android/android-capability-operations.test.ts": 10, + "src/main/emulator/android/android-device-inventory.test.ts": 12, + "src/main/emulator/android/android-input-commands.test.ts": 5, + "src/main/emulator/android/android-input-mapping.test.ts": 9, + "src/main/emulator/android/android-logcat.test.ts": 5, + "src/main/emulator/android/android-permissions.test.ts": 8, + "src/main/emulator/android/android-sdk-discovery.test.ts": 7, + "src/main/emulator/android/avd-manager.test.ts": 11, + "src/main/emulator/android/scrcpy-server-deploy.test.ts": 7, + "src/main/emulator/android/scrcpy-video-frame-parser.test.ts": 11, + "src/main/emulator/android/uiautomator-tree.test.ts": 12, + "src/main/emulator/backends/android-emulator-backend.test.ts": 24, + "src/main/emulator/backends/ios-emulator-backend.test.ts": 88, + "src/main/emulator/emulator-availability.test.ts": 7, + "src/main/emulator/emulator-bridge.test.ts": 122, + "src/main/emulator/emulator-gesture-sender.test.ts": 131, + "src/main/emulator/emulator-session-registry.test.ts": 5, + "src/main/emulator/emulator-start-lease-registry.test.ts": 62, + "src/main/emulator/mjpeg-frame-parser.test.ts": 8, + "src/main/emulator/mjpeg-frame-stream.test.ts": 34, + "src/main/emulator/scrcpy-video-registry.test.ts": 11, + "src/main/emulator/serve-sim-accessibility-tree.test.ts": 90, + "src/main/emulator/serve-sim-ax-normalization.test.ts": 12, + "src/main/emulator/serve-sim-detached-session.test.ts": 11, + "src/main/emulator/serve-sim-execution.test.ts": 25, + "src/main/emulator/serve-sim-helper-processes.test.ts": 8, + "src/main/emulator/serve-sim-runtime-materializer.test.ts": 49, + "src/main/emulator/simctl-simulator-devices.test.ts": 11, + "src/main/ephemeral-vm-recipe-runner.test.ts": 296, + "src/main/ephemeral-vm-resume-integrity.test.ts": 11, + "src/main/ephemeral-vm-runtime-service.test.ts": 522, + "src/main/ephemeral-vm-runtime-ssh-cleanup.test.ts": 36, + "src/main/external-editor-launch.test.ts": 19, + "src/main/fish-history-session.test.ts": 22, + "src/main/gemini/hook-service.test.ts": 15, + "src/main/ghostty/discovery.test.ts": 14, + "src/main/ghostty/index.test.ts": 20, + "src/main/ghostty/mapper-extended.test.ts": 11, + "src/main/ghostty/mapper.test.ts": 11, + "src/main/ghostty/parser.test.ts": 8, + "src/main/ghostty/theme-import.test.ts": 14, + "src/main/ghostty/theme-resolution.test.ts": 10, + "src/main/git-bash.test.ts": 13, + "src/main/git/add-sparse-worktree.test.ts": 22, + "src/main/git/admission-tier-plumbing.test.ts": 10, + "src/main/git/branch-rename.test.ts": 8, + "src/main/git/canonical-repo-key.test.ts": 6, + "src/main/git/check-ignored-paths.test.ts": 15, + "src/main/git/coalesced-probe.test.ts": 9, + "src/main/git/command-runner/gh-exec-file-deadline.test.ts": 20, + "src/main/git/command-runner/gh-spawn-boundary.test.ts": 566, + "src/main/git/command-runner/git-admission-output-parity.test.ts": 120, + "src/main/git/command-runner/git-admission-span.test.ts": 175, + "src/main/git/command-runner/git-admission-storm-measurement.test.ts": 25859, + "src/main/git/command-runner/git-command-timeout-behavior.test.ts": 1728, + "src/main/git/command-runner/git-command-timeout.test.ts": 8, + "src/main/git/command-runner/git-exec-admission-lifetime.test.ts": 375, + "src/main/git/command-runner/git-spawn-admission-lifetime.test.ts": 15, + "src/main/git/command-runner/git-stream-admission-lifetime.test.ts": 120, + "src/main/git/command-runner/git-subprocess-admission.test.ts": 448, + "src/main/git/command-runner/hosted-cli-deadline-log.test.ts": 22, + "src/main/git/command-runner/wsl-host-failure.test.ts": 17, + "src/main/git/commit-object-ref.test.ts": 8, + "src/main/git/commit.test.ts": 6, + "src/main/git/exact-ref-probe.test.ts": 17, + "src/main/git/fetch-error-classification.test.ts": 9, + "src/main/git/fork-remote-refspec.test.ts": 14, + "src/main/git/fork-remote-stale-branch-refspec.test.ts": 10, + "src/main/git/gh-rate-limit-breaker.test.ts": 20, + "src/main/git/git-capability-state.test.ts": 14, + "src/main/git/git-status-read-lease-owner.test.ts": 15, + "src/main/git/git-upstream-status-read-owner.test.ts": 18, + "src/main/git/hosted-remote-url.test.ts": 6, + "src/main/git/huge-folder-ignore.test.ts": 20, + "src/main/git/local-repo-ref-maintenance.test.ts": 98, + "src/main/git/pack-refs-lock-ownership.test.ts": 36, + "src/main/git/porcelain-v1-records.test.ts": 140, + "src/main/git/remote-ref-probe-cache.test.ts": 13, + "src/main/git/remote-url-probe.test.ts": 9, + "src/main/git/remote.test.ts": 51, + "src/main/git/remove-worktree-branch-cleanup.test.ts": 16, + "src/main/git/remove-worktree-clean-preflight.test.ts": 14, + "src/main/git/remove-worktree.test.ts": 35, + "src/main/git/repo-api-parity.test.ts": 6, + "src/main/git/repo-branch-conflict-batched-probe.test.ts": 11, + "src/main/git/repo-branch-conflict-real-git.test.ts": 157, + "src/main/git/repo-branch-conflict.test.ts": 20, + "src/main/git/repo-clone-path.test.ts": 6, + "src/main/git/repo-default-base-timeout.test.ts": 11, + "src/main/git/repo-default-remote.test.ts": 11, + "src/main/git/repo-detection.test.ts": 348, + "src/main/git/repo-ref-maintenance-real-git.test.ts": 1092, + "src/main/git/repo-remote-drift-real.test.ts": 103, + "src/main/git/repo-remote-drift.test.ts": 8, + "src/main/git/repo-search-ref-compat.test.ts": 10, + "src/main/git/repo-username.test.ts": 30, + "src/main/git/repo.test.ts": 2330, + "src/main/git/runner-command-exec.test.ts": 530, + "src/main/git/runner-gh-host-args.test.ts": 8, + "src/main/git/runner-gh-rate-limit-breaker.test.ts": 32, + "src/main/git/runner-windows-host-environment.test.ts": 88, + "src/main/git/runner-wsl-direct-read.test.ts": 1710, + "src/main/git/runner-wsl-gh-fallback.test.ts": 857, + "src/main/git/runner-wsl-linked-gitdir-timeout.test.ts": 20, + "src/main/git/runner-wsl-login-shell-capture.test.ts": 18, + "src/main/git/runner-wsl-read-routing.test.ts": 15, + "src/main/git/runner.test.ts": 32, + "src/main/git/settled-diff-cache-bounds.test.ts": 7, + "src/main/git/source-control/bulk-pathspec-command-line-budget.test.ts": 128, + "src/main/git/source-control/resolve-git-dir.test.ts": 6, + "src/main/git/source-control/wsl-tracked-pathspec-banner.test.ts": 20, + "src/main/git/status-branch-compare-real-ref.test.ts": 137, + "src/main/git/status-branch-compare.test.ts": 19, + "src/main/git/status-branch-line-total-exec-contract.test.ts": 751, + "src/main/git/status-branch-line-total-real-git.test.ts": 728, + "src/main/git/status-branch-line-total-relay-parity.test.ts": 301, + "src/main/git/status-conflict-operations.test.ts": 19, + "src/main/git/status-conflict-overlap.bench.test.ts": 25, + "src/main/git/status-cquoted-paths.test.ts": 79, + "src/main/git/status-diff-settled-cache.test.ts": 152, + "src/main/git/status-diff.test.ts": 37, + "src/main/git/status-discard-and-bulk-staging.test.ts": 73, + "src/main/git/status-discard-symlink.test.ts": 429, + "src/main/git/status-line-stats-host-paths.test.ts": 14, + "src/main/git/status-pathspec-literals.test.ts": 352, + "src/main/git/status-porcelain-parser.test.ts": 7, + "src/main/git/status-read-coalescing.test.ts": 37, + "src/main/git/status-shared-symlinks.test.ts": 825, + "src/main/git/status-submodule-path-cache.test.ts": 894, + "src/main/git/status-submodule.test.ts": 45, + "src/main/git/status-symlink-probe-budget.test.ts": 44, + "src/main/git/status-upstream-negative-cache.test.ts": 990, + "src/main/git/status-upstream-probe-churn.test.ts": 42, + "src/main/git/status-upstream-ref.test.ts": 13, + "src/main/git/status-wsl-pathspecs.test.ts": 14, + "src/main/git/status.test.ts": 72, + "src/main/git/upstream-deferred-fork-remote-real.test.ts": 49, + "src/main/git/upstream.test.ts": 179, + "src/main/git/worktree-add-creation-config.test.ts": 38, + "src/main/git/worktree-add-local-base-refresh.test.ts": 16, + "src/main/git/worktree-add-local-base-suggestion.test.ts": 15, + "src/main/git/worktree-add-timeout-override.test.ts": 12, + "src/main/git/worktree-base-divergence-real-git.test.ts": 1158, + "src/main/git/worktree-base-divergence.test.ts": 39, + "src/main/git/worktree-base-ref-probe.test.ts": 16, + "src/main/git/worktree-common-dir-comparison.test.ts": 6, + "src/main/git/worktree-configured-paths-concurrency.test.ts": 27, + "src/main/git/worktree-create-preparation-real-git.test.ts": 1004, + "src/main/git/worktree-created-description-real-git.test.ts": 1472, + "src/main/git/worktree-deferred-removal-real-git.test.ts": 269, + "src/main/git/worktree-diff-stamp-guest-gitdir.test.ts": 6, + "src/main/git/worktree-diff-stamp-host-paths.test.ts": 8, + "src/main/git/worktree-git-capabilities.test.ts": 13, + "src/main/git/worktree-graph-listing.test.ts": 13, + "src/main/git/worktree-include-file.test.ts": 83, + "src/main/git/worktree-list-paths.test.ts": 228, + "src/main/git/worktree-list-porcelain.test.ts": 26, + "src/main/git/worktree-listing-created-sparse-distro.test.ts": 9, + "src/main/git/worktree-listing-sparse-distro.test.ts": 8, + "src/main/git/worktree-move.test.ts": 12, + "src/main/git/worktree-mutation-route-invalidation.test.ts": 25, + "src/main/git/worktree-porcelain-parsing.test.ts": 14, + "src/main/git/worktree-preparation-base-oid.test.ts": 14, + "src/main/git/worktree-remove-branch-deletion.test.ts": 17, + "src/main/git/worktree-scan-cache-annotation-reuse.test.ts": 8, + "src/main/git/worktree-scan-cache-sharing.test.ts": 52, + "src/main/git/worktree-separate-git-dir.test.ts": 350, + "src/main/git/worktree-shared-directories.test.ts": 726, + "src/main/git/worktree-sparse-checkout-cache.test.ts": 31, + "src/main/git/worktree-sparse-checkout.test.ts": 276, + "src/main/git/worktree-sparse-state-host-paths.test.ts": 5, + "src/main/git/worktree-symlink-detection.test.ts": 11, + "src/main/git/wsl-direct-git-read-commands.test.ts": 8, + "src/main/git/wsl-linked-worktree-git-route-invalidation.test.ts": 9, + "src/main/git/wsl-linked-worktree-git-routing.test.ts": 57, + "src/main/git/wsl-process-group-termination.test.ts": 8, + "src/main/gitea/client.test.ts": 145, + "src/main/gitea/pull-request-creation.test.ts": 82, + "src/main/gitea/pull-request-mappers.test.ts": 5, + "src/main/gitea/repository-ref.test.ts": 19, + "src/main/github/auth-diagnose.test.ts": 10, + "src/main/github/client-create-pr.test.ts": 39, + "src/main/github/client-file-viewed.test.ts": 10, + "src/main/github/client-issue-origin-preference.test.ts": 17, + "src/main/github/client-issue-source.test.ts": 28, + "src/main/github/client-merge-queue-auto-merge.test.ts": 70, + "src/main/github/client-merged-pr-visibility.test.ts": 19, + "src/main/github/client-pr-branch-discovery.test.ts": 16, + "src/main/github/client-pr-check-details.test.ts": 36, + "src/main/github/client-pr-checks.test.ts": 42, + "src/main/github/client-pr-comment-reactions.test.ts": 13, + "src/main/github/client-pr-conflict-summary.test.ts": 26, + "src/main/github/client-pr-fallback-number.test.ts": 23, + "src/main/github/client-pr-linked-lookup.test.ts": 18, + "src/main/github/client-pr-local-runtime.test.ts": 32, + "src/main/github/client-pr-push-target.test.ts": 24, + "src/main/github/client-pr-state.test.ts": 10, + "src/main/github/client-rate-limit-block.test.ts": 12, + "src/main/github/client-ssh-provider-execution-boundary.test.ts": 23, + "src/main/github/client-stack-merge-guard.test.ts": 32, + "src/main/github/client-starred.test.ts": 12, + "src/main/github/client-tracked-upstream-fork-owner.test.ts": 21, + "src/main/github/client-tracked-upstream-snapshot.test.ts": 232, + "src/main/github/client-work-item-check-summary.test.ts": 12, + "src/main/github/client-work-items-query-paging.test.ts": 35, + "src/main/github/client-work-items.test.ts": 18, + "src/main/github/comment-reactions.test.ts": 3, + "src/main/github/conflict-summary.test.ts": 33, + "src/main/github/default-branch-stale-pr.test.ts": 11, + "src/main/github/gh-utils-concurrency.test.ts": 6, + "src/main/github/gh-utils.test.ts": 88, + "src/main/github/github-api-repository.test.ts": 113, + "src/main/github/github-enterprise-repository.test.ts": 36, + "src/main/github/github-pr-stack.test.ts": 46, + "src/main/github/github-remote-identity-parsing.test.ts": 9, + "src/main/github/github-repository-identity.fork-owner-repo.test.ts": 13, + "src/main/github/github-repository-identity.signed-cache.test.ts": 9, + "src/main/github/github-repository-identity.ssh-host-alias.test.ts": 33, + "src/main/github/issues.test.ts": 18, + "src/main/github/pr-head-tracking-ref.test.ts": 13, + "src/main/github/pr-refresh-coordinator-active-burst-pacing.test.ts": 271, + "src/main/github/pr-refresh-coordinator-active-visible-priority.test.ts": 192, + "src/main/github/pr-refresh-coordinator-alias-coalescing.test.ts": 175, + "src/main/github/pr-refresh-coordinator-rate-limit-budget.test.ts": 223, + "src/main/github/pr-refresh-coordinator-refresh-events.test.ts": 185, + "src/main/github/pr-refresh-coordinator-visible-follow-up.test.ts": 163, + "src/main/github/pr-refresh-error-classification.test.ts": 8, + "src/main/github/pr-refresh-queue-growth-bound.test.ts": 439, + "src/main/github/pr-refresh-validation-backoff.test.ts": 14, + "src/main/github/pr-review-comment-lines.test.ts": 6, + "src/main/github/pr-start-point-compare-base.test.ts": 15, + "src/main/github/pr-start-point.test.ts": 20, + "src/main/github/project-view-host-auth.test.ts": 16, + "src/main/github/project-view.test.ts": 22, + "src/main/github/project-view/mutations.test.ts": 14, + "src/main/github/project-view/project-field-mutations.test.ts": 6, + "src/main/github/project-view/project-view-table.test.ts": 11, + "src/main/github/project-view/repository-field-options.test.ts": 5, + "src/main/github/rate-limit.test.ts": 19, + "src/main/github/review-head-remote.test.ts": 11, + "src/main/github/stacked-pr-creation.test.ts": 23, + "src/main/github/work-item-details-api-parity.test.ts": 7, + "src/main/github/work-item-details-concurrency.test.ts": 7, + "src/main/github/work-item-details-enterprise-host.test.ts": 20, + "src/main/github/work-item-details-file-viewed.test.ts": 18, + "src/main/github/work-item-details-pr-files.test.ts": 22, + "src/main/github/work-item-details.test.ts": 29, + "src/main/gitlab/client-mr-auth-rate-limit.test.ts": 14, + "src/main/gitlab/client-mr-branch-lookup.test.ts": 27, + "src/main/gitlab/client-mr-job-ci.test.ts": 12, + "src/main/gitlab/client-mr-listing.test.ts": 15, + "src/main/gitlab/client-mr-review-actions.test.ts": 11, + "src/main/gitlab/client-work-items.test.ts": 16, + "src/main/gitlab/client.test.ts": 19, + "src/main/gitlab/gitlab-known-host-probe-wsl-fallback.test.ts": 15, + "src/main/gitlab/gitlab-known-host-probe.test.ts": 23, + "src/main/gitlab/gl-utils.test.ts": 41, + "src/main/gitlab/issues.test.ts": 23, + "src/main/gitlab/mappers-workitem.test.ts": 5, + "src/main/gitlab/mappers.test.ts": 23, + "src/main/gitlab/merge-request-creation.test.ts": 11, + "src/main/gitlab/mr-head-tracking-ref.test.ts": 10, + "src/main/gitlab/project-ref-parser.test.ts": 12, + "src/main/gitlab/work-item-details.test.ts": 31, + "src/main/global-fetch-call-site-audit.test.ts": 135, + "src/main/grok-accounts/status.test.ts": 6, + "src/main/grok/grok-hook-config-file.test.ts": 99, + "src/main/grok/grok-hook-owners.test.ts": 26, + "src/main/grok/grok-hook-remnant-removal.test.ts": 15, + "src/main/grok/hook-service.test.ts": 38, + "src/main/grok/windows-grok-hook-script.test.ts": 9, + "src/main/grok/windows-hook-launcher-chain.test.ts": 9, + "src/main/hang-watchdog/hang-detection-marker.test.ts": 7, + "src/main/hang-watchdog/hang-watchdog-detection-loop.test.ts": 11, + "src/main/hang-watchdog/hang-watchdog-worker-path.test.ts": 8, + "src/main/hang-watchdog/main-thread-hang-telemetry.test.ts": 6, + "src/main/hang-watchdog/main-thread-hang-watchdog-entry.test.ts": 19, + "src/main/hang-watchdog/main-thread-hang-watchdog.test.ts": 26, + "src/main/headless-automation-dispatcher-source-boundary.test.ts": 4, + "src/main/hermes/hook-service.test.ts": 196, + "src/main/hooks-effective-hook-resolution.test.ts": 173, + "src/main/hooks-issue-command.test.ts": 237, + "src/main/hooks-orca-yaml-parsing.test.ts": 37, + "src/main/hooks-runner.test.ts": 103, + "src/main/hooks-setup-runner-script.test.ts": 171, + "src/main/hooks.test.ts": 316, + "src/main/host-tree-removal-asar.electron.test.ts": 285, + "src/main/host/deferred-secret-protection-report.test.ts": 16, + "src/main/host/electron-secret-store.test.ts": 11, + "src/main/host/secret-protection-report.test.ts": 13, + "src/main/i18n/main-i18n-lazy-locale.test.ts": 270, + "src/main/ipc/agent-hooks.test.ts": 472, + "src/main/ipc/agent-pane-authority-ownership.test.ts": 4, + "src/main/ipc/ai-vault-scan-coalescing.test.ts": 322, + "src/main/ipc/ai-vault.test.ts": 159, + "src/main/ipc/app.test.ts": 24, + "src/main/ipc/automations-external-scope.test.ts": 110, + "src/main/ipc/bounded-warning-dedupe.test.ts": 8, + "src/main/ipc/browser-client-page-metadata-ipc.test.ts": 19, + "src/main/ipc/browser-preview-tool-authorization.test.ts": 81, + "src/main/ipc/browser-session-profile-ipc.test.ts": 10, + "src/main/ipc/browser-tab-registration-wait.test.ts": 12, + "src/main/ipc/browser.test.ts": 83, + "src/main/ipc/cli-appimage-stale-registration.test.ts": 214, + "src/main/ipc/cli.test.ts": 62, + "src/main/ipc/codex-config-sync.test.ts": 13, + "src/main/ipc/command-path-resolver.test.ts": 13, + "src/main/ipc/computer-use-permissions.test.ts": 9, + "src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts": 39, + "src/main/ipc/crash-reporting-renderer-error-report-attribution.test.ts": 12, + "src/main/ipc/crash-reporting-replay-guard-wedge-burst.test.ts": 24, + "src/main/ipc/crash-reporting.test.ts": 43, + "src/main/ipc/created-worktree-reconciliation.test.ts": 72, + "src/main/ipc/created-worktree-root-prune.test.ts": 19, + "src/main/ipc/dashboard-payload-validation.test.ts": 74, + "src/main/ipc/dashboard-popout.test.ts": 13, + "src/main/ipc/deferred-emoji-shortcode-dataset.test.ts": 65, + "src/main/ipc/developer-permissions.test.ts": 17, + "src/main/ipc/diagnostics.test.ts": 20, + "src/main/ipc/doc-preview-grant-ipc.test.ts": 10, + "src/main/ipc/dropped-path-resolution.test.ts": 19, + "src/main/ipc/emulator-stream-listener-cleanup.test.ts": 12, + "src/main/ipc/ephemeral-vm-provision-cancel.test.ts": 58, + "src/main/ipc/ephemeral-vm-provisioned-root-ref.test.ts": 447, + "src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts": 94, + "src/main/ipc/ephemeral-vm.test.ts": 595, + "src/main/ipc/feedback-image-attachments.test.ts": 67, + "src/main/ipc/feedback.test.ts": 132, + "src/main/ipc/filesystem-allowed-roots.test.ts": 334, + "src/main/ipc/filesystem-auth.test.ts": 678, + "src/main/ipc/filesystem-branch-compare-diff.test.ts": 24, + "src/main/ipc/filesystem-commit-message-generation.test.ts": 32, + "src/main/ipc/filesystem-commit-message-model-discovery.test.ts": 25, + "src/main/ipc/filesystem-conflict-operation-routing.test.ts": 14, + "src/main/ipc/filesystem-download-transfers.test.ts": 106, + "src/main/ipc/filesystem-git-commit-dispatch.test.ts": 18, + "src/main/ipc/filesystem-git-status-staging.test.ts": 82, + "src/main/ipc/filesystem-import-ssh-ops.test.ts": 23, + "src/main/ipc/filesystem-import-ssh-path-safety.test.ts": 18, + "src/main/ipc/filesystem-import-ssh.test.ts": 31, + "src/main/ipc/filesystem-import.test.ts": 64, + "src/main/ipc/filesystem-list-files-git-directory-expansion.test.ts": 325, + "src/main/ipc/filesystem-list-files-git-fallback-real.test.ts": 271, + "src/main/ipc/filesystem-list-files-install-rg.test.ts": 23, + "src/main/ipc/filesystem-list-files.test.ts": 253, + "src/main/ipc/filesystem-markdown-document-listing.test.ts": 17, + "src/main/ipc/filesystem-mutations.test.ts": 49, + "src/main/ipc/filesystem-path-containment-remote-enoent.test.ts": 8, + "src/main/ipc/filesystem-pull-request-field-generation.test.ts": 18, + "src/main/ipc/filesystem-search-file-paths.test.ts": 257, + "src/main/ipc/filesystem-search-git.test.ts": 150, + "src/main/ipc/filesystem-search-rg-timeout.test.ts": 50, + "src/main/ipc/filesystem-watcher-canonical-root-paths.test.ts": 320, + "src/main/ipc/filesystem-watcher-dormant-rearm.test.ts": 170, + "src/main/ipc/filesystem-watcher-event-batch.test.ts": 9, + "src/main/ipc/filesystem-watcher-ignore.test.ts": 7, + "src/main/ipc/filesystem-watcher-large-batch.test.ts": 150, + "src/main/ipc/filesystem-watcher-local-events.test.ts": 240, + "src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts": 818, + "src/main/ipc/filesystem-watcher-native-capacity.test.ts": 67, + "src/main/ipc/filesystem-watcher-real.test.ts": 488, + "src/main/ipc/filesystem-watcher-remote-batch.test.ts": 27, + "src/main/ipc/filesystem-watcher-remote-cancellation.test.ts": 324, + "src/main/ipc/filesystem-watcher-remote-capacity.test.ts": 31, + "src/main/ipc/filesystem-watcher-remote-rearm.test.ts": 20, + "src/main/ipc/filesystem-watcher-removal-deadline.test.ts": 129, + "src/main/ipc/filesystem-watcher-terminal-resync.test.ts": 21, + "src/main/ipc/filesystem-watcher-unwatchable-roots.test.ts": 24, + "src/main/ipc/filesystem-watcher-wsl.test.ts": 35, + "src/main/ipc/filesystem-watcher.test.ts": 1789, + "src/main/ipc/filesystem.test.ts": 70, + "src/main/ipc/floating-workspace-directory.test.ts": 23, + "src/main/ipc/folder-repo-git-upgrade.test.ts": 2257, + "src/main/ipc/git-status-upstream-ref-watch-request.test.ts": 13, + "src/main/ipc/github-ipc-channel-parity.test.ts": 13, + "src/main/ipc/github-issue-source-preference.test.ts": 16, + "src/main/ipc/github-pr-refresh-routing.test.ts": 31, + "src/main/ipc/github-repo-access-guards.test.ts": 24, + "src/main/ipc/github-ssh-connection-routing.test.ts": 10, + "src/main/ipc/github-star-telemetry.test.ts": 17, + "src/main/ipc/github-work-item-args.test.ts": 12, + "src/main/ipc/github-wsl-runtime-routing.test.ts": 21, + "src/main/ipc/gitlab-repo-access.test.ts": 6, + "src/main/ipc/gitlab.test.ts": 27, + "src/main/ipc/hosted-review.test.ts": 23, + "src/main/ipc/jira-cancellable-requests.test.ts": 12, + "src/main/ipc/keybindings.test.ts": 8, + "src/main/ipc/linear.test.ts": 9, + "src/main/ipc/local-log-tail.test.ts": 9, + "src/main/ipc/local-network-connection-test.test.ts": 84, + "src/main/ipc/local-worktree-runtime-options.test.ts": 10, + "src/main/ipc/macos-keyboard-layout-change-notifications.test.ts": 13, + "src/main/ipc/macos-keyboard-layout-snapshot.test.ts": 7, + "src/main/ipc/markdown-documents.test.ts": 5, + "src/main/ipc/minimax-credentials.test.ts": 17, + "src/main/ipc/mobile.test.ts": 66, + "src/main/ipc/native-chat-subscribe-lifecycle.test.ts": 18, + "src/main/ipc/native-chat.test.ts": 1703, + "src/main/ipc/notebook.test.ts": 13, + "src/main/ipc/notifications-custom-sound.test.ts": 13, + "src/main/ipc/notifications-delivery-gating.test.ts": 19, + "src/main/ipc/notifications-message-formatting.test.ts": 32, + "src/main/ipc/notifications-mobile-fanout.test.ts": 17, + "src/main/ipc/notifications-permission-onboarding.test.ts": 27, + "src/main/ipc/notifications-retention-lifecycle.test.ts": 27, + "src/main/ipc/orca-profile-auth-handlers.test.ts": 16, + "src/main/ipc/orca-profile-org-members-handlers.test.ts": 10, + "src/main/ipc/orca-profiles.test.ts": 28, + "src/main/ipc/parcel-watcher-child-launch.test.ts": 14, + "src/main/ipc/parcel-watcher-child-registry.test.ts": 60, + "src/main/ipc/parcel-watcher-crash-fuse.test.ts": 4, + "src/main/ipc/parcel-watcher-disconnect-termination.test.ts": 46, + "src/main/ipc/parcel-watcher-entry-path.test.ts": 8, + "src/main/ipc/parcel-watcher-event-cancellation.test.ts": 12, + "src/main/ipc/parcel-watcher-event-delivery.test.ts": 8, + "src/main/ipc/parcel-watcher-process-entry.test.ts": 83, + "src/main/ipc/parcel-watcher-process.test.ts": 395, + "src/main/ipc/parcel-watcher-root-path-rewrite.test.ts": 19, + "src/main/ipc/parcel-watcher-shallow-subscription.test.ts": 11, + "src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts": 340, + "src/main/ipc/parcel-watcher-supervisor-capacity.test.ts": 134, + "src/main/ipc/parcel-watcher-unsubscribe-timeout.test.ts": 38, + "src/main/ipc/pet-bundle.test.ts": 6, + "src/main/ipc/pet.test.ts": 35, + "src/main/ipc/plugin-marketplaces.test.ts": 13, + "src/main/ipc/plugins.test.ts": 18, + "src/main/ipc/preflight-agent-detection-no-subprocess.test.ts": 22, + "src/main/ipc/preflight-agent-detection.test.ts": 42, + "src/main/ipc/preflight-agent-refresh.test.ts": 21, + "src/main/ipc/preflight-command-exec.test.ts": 8, + "src/main/ipc/preflight-host-cli-status.test.ts": 27, + "src/main/ipc/preflight-remote-ssh.test.ts": 16, + "src/main/ipc/preflight-wsl-agent-detection.test.ts": 55, + "src/main/ipc/preflight-wsl-command.test.ts": 17, + "src/main/ipc/pty-activation-inventory-scope.test.ts": 23, + "src/main/ipc/pty-agent-session-write-gate.test.ts": 141, + "src/main/ipc/pty-applied-size-reporting.test.ts": 48, + "src/main/ipc/pty-buffer-snapshot-dispatch.test.ts": 64, + "src/main/ipc/pty-codex-account-attribution.test.ts": 46, + "src/main/ipc/pty-controller-owner-recovery.test.ts": 53, + "src/main/ipc/pty-controller-ownership-routing.test.ts": 37, + "src/main/ipc/pty-controller-process-inventory.test.ts": 17, + "src/main/ipc/pty-controller-spawn-admission.test.ts": 49, + "src/main/ipc/pty-cumulative-ack-accounting.test.ts": 95, + "src/main/ipc/pty-daemon-controller-teardown.test.ts": 44, + "src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts": 72, + "src/main/ipc/pty-daemon-spawn-codex-auth.test.ts": 82, + "src/main/ipc/pty-daemon-spawn-session-identity.test.ts": 60, + "src/main/ipc/pty-daemon-spawn-wsl-runtime.test.ts": 67, + "src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts": 48, + "src/main/ipc/pty-dead-owner-respawn.test.ts": 44, + "src/main/ipc/pty-delivery-health-heal.test.ts": 117, + "src/main/ipc/pty-dispatcher-handshake-osc-answers.test.ts": 83, + "src/main/ipc/pty-encoding.test.ts": 7, + "src/main/ipc/pty-global-renderer-credit.test.ts": 134, + "src/main/ipc/pty-hidden-at-spawn-mark.test.ts": 120, + "src/main/ipc/pty-hidden-delivery-gate.test.ts": 8, + "src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts": 67, + "src/main/ipc/pty-ipc-producer-flow-control.test.ts": 57, + "src/main/ipc/pty-listener-teardown-and-orphans.test.ts": 99, + "src/main/ipc/pty-login-shell-startup-commands.test.ts": 113, + "src/main/ipc/pty-management.test.ts": 46, + "src/main/ipc/pty-output-batching-drain.test.ts": 66, + "src/main/ipc/pty-output-drain-rounds.test.ts": 110, + "src/main/ipc/pty-pane-claim-arbitration.test.ts": 137, + "src/main/ipc/pty-pane-materialization-race.test.ts": 501, + "src/main/ipc/pty-pane-reservation-settlement.test.ts": 71, + "src/main/ipc/pty-pending-data-drain-queue-differential.test.ts": 955, + "src/main/ipc/pty-pending-data-drain-queue.test.ts": 19, + "src/main/ipc/pty-pending-data-drain-scheduler-differential.test.ts": 19, + "src/main/ipc/pty-pending-output-cap.test.ts": 97, + "src/main/ipc/pty-pending-projection-admissions.test.ts": 86, + "src/main/ipc/pty-persisted-incarnation-repair.test.ts": 38, + "src/main/ipc/pty-producer-flow-control.test.ts": 16, + "src/main/ipc/pty-renderer-inflight-credit.test.ts": 156, + "src/main/ipc/pty-renderer-lifecycle-delivery-reset.test.ts": 77, + "src/main/ipc/pty-renderer-liveness-guard.test.ts": 82, + "src/main/ipc/pty-renderer-send-failure-recovery.test.ts": 51, + "src/main/ipc/pty-restore-record-seeding.test.ts": 156, + "src/main/ipc/pty-restored-appimage-cli-shim-refresh.test.ts": 14, + "src/main/ipc/pty-runtime-kill-and-exit.test.ts": 105, + "src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts": 40, + "src/main/ipc/pty-serializer-settlement-mapping.test.ts": 146, + "src/main/ipc/pty-session-liveness-and-ownership.test.ts": 42, + "src/main/ipc/pty-spawn-codex-home-unavailable.test.ts": 43, + "src/main/ipc/pty-spawn-cwd-fallback.test.ts": 69, + "src/main/ipc/pty-spawn-env-agent-overlays.test.ts": 116, + "src/main/ipc/pty-spawn-env-codex-home-routing.test.ts": 132, + "src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts": 118, + "src/main/ipc/pty-spawn-env-terminal-basics.test.ts": 1128, + "src/main/ipc/pty-spawn-runtime-handle-binding.test.ts": 135, + "src/main/ipc/pty-ssh-stop-verdict.test.ts": 92, + "src/main/ipc/pty-ssh-undelivered-kill.test.ts": 149, + "src/main/ipc/pty-startup-barrier-and-listing.test.ts": 60, + "src/main/ipc/pty-startup-barrier-ordering.test.ts": 3, + "src/main/ipc/pty-startup-swap-window-presence.test.ts": 81, + "src/main/ipc/pty-windows-shell-selection.test.ts": 131, + "src/main/ipc/pty-write-ipc-validation.test.ts": 125, + "src/main/ipc/pty-wsl-cwd-validation.test.ts": 75, + "src/main/ipc/pty/delivery/attached-pty-size.test.ts": 13, + "src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts": 111, + "src/main/ipc/pty/ipc/spawn-push-target-materialization-real-git.test.ts": 258, + "src/main/ipc/pty/ipc/spawn-push-target-materialization.test.ts": 11, + "src/main/ipc/pty/ipc/spawn-reattach-size-cache.test.ts": 14, + "src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts": 25, + "src/main/ipc/pty/pane/launch-authority.test.ts": 9, + "src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts": 26, + "src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts": 14, + "src/main/ipc/pty/register-headless-runtime.test.ts": 6, + "src/main/ipc/pty/runtime/queried-host-kinds.test.ts": 8, + "src/main/ipc/pty/runtime/spawn-commit-pty-size.test.ts": 7, + "src/main/ipc/rate-limits.test.ts": 7, + "src/main/ipc/readdir-error-diagnostics.test.ts": 10, + "src/main/ipc/register-core-handlers/register-core-handlers.test.ts": 20, + "src/main/ipc/remote-watcher-event-batch.test.ts": 29, + "src/main/ipc/remote-workspace-cache.test.ts": 9, + "src/main/ipc/remote-workspace-patch-queue.test.ts": 173, + "src/main/ipc/remote-workspace-snapshot-normalization.test.ts": 10, + "src/main/ipc/remote-workspace-stale-resync.test.ts": 162, + "src/main/ipc/remote-workspace.test.ts": 29, + "src/main/ipc/renderer-shutdown-checkpoint.test.ts": 18, + "src/main/ipc/renderer-terminal-serializer-readiness.test.ts": 11, + "src/main/ipc/repos-add-linked-worktree.test.ts": 46, + "src/main/ipc/repos-create.test.ts": 34, + "src/main/ipc/repos-execution-host-catalog.test.ts": 22, + "src/main/ipc/repos-local-add-and-project-setup.test.ts": 54, + "src/main/ipc/repos-local-clone-lifecycle.test.ts": 245, + "src/main/ipc/repos-nested-import.test.ts": 51, + "src/main/ipc/repos-nested-scan.test.ts": 36, + "src/main/ipc/repos-picker.test.ts": 8, + "src/main/ipc/repos-remote-base-ref-queries.test.ts": 30, + "src/main/ipc/repos-remote-client-events.test.ts": 1415, + "src/main/ipc/repos-remote-git-username.test.ts": 6, + "src/main/ipc/repos-remote.test.ts": 66, + "src/main/ipc/repos-sparse-presets.test.ts": 27, + "src/main/ipc/repos/remote-repo-registration.test.ts": 7, + "src/main/ipc/rg-availability.test.ts": 9, + "src/main/ipc/runtime-environment-browser-client-host-handler.test.ts": 14, + "src/main/ipc/runtime-environment-capability-evidence.test.ts": 9, + "src/main/ipc/runtime-environment-diagnostics-broadcast.test.ts": 4, + "src/main/ipc/runtime-environment-federated-read-routing.test.ts": 34, + "src/main/ipc/runtime-environment-federated-read-transport.bench.test.ts": 10, + "src/main/ipc/runtime-environment-removal-storage.test.ts": 565, + "src/main/ipc/runtime-environment-request-connections.test.ts": 1282, + "src/main/ipc/runtime-environment-revision-guard.test.ts": 7, + "src/main/ipc/runtime-environment-status-connection.test.ts": 441, + "src/main/ipc/runtime-environment-status-recovery.test.ts": 23, + "src/main/ipc/runtime-environment-support-routing.test.ts": 62, + "src/main/ipc/runtime-environment-transport-routing-tailscale-hint.test.ts": 853, + "src/main/ipc/runtime-environments-call-routing.test.ts": 187, + "src/main/ipc/runtime-environments-capability-cache.test.ts": 129, + "src/main/ipc/runtime-environments-pairing.test.ts": 66, + "src/main/ipc/runtime-environments-status-diagnostics.test.ts": 37, + "src/main/ipc/runtime-environments-subscription-lifecycle.test.ts": 35, + "src/main/ipc/runtime-environments-subscription-routing.test.ts": 26, + "src/main/ipc/runtime-environments-subscription-teardown.test.ts": 38, + "src/main/ipc/runtime-subscribe-lifecycle.test.ts": 10, + "src/main/ipc/runtime-watcher-pending-assignment.test.ts": 281, + "src/main/ipc/runtime-watcher-process-pool.test.ts": 46, + "src/main/ipc/runtime.test.ts": 23, + "src/main/ipc/settings.test.ts": 30, + "src/main/ipc/shallow-watch-delivery-probe.test.ts": 28, + "src/main/ipc/shell.test.ts": 47, + "src/main/ipc/skill-cloud-install-ipc-schemas.test.ts": 13, + "src/main/ipc/skill-cloud-ipc-handlers.test.ts": 12, + "src/main/ipc/skill-install-management-ipc-handlers.test.ts": 19, + "src/main/ipc/skill-install-progress-ipc.test.ts": 7, + "src/main/ipc/skill-ipc-main-window.test.ts": 9, + "src/main/ipc/skills.test.ts": 17, + "src/main/ipc/source-control-ai-linked-issue.test.ts": 14, + "src/main/ipc/speech.test.ts": 12, + "src/main/ipc/ssh-app-shutdown.test.ts": 45, + "src/main/ipc/ssh-browse.test.ts": 586, + "src/main/ipc/ssh-disconnect-cancellation.test.ts": 1118, + "src/main/ipc/ssh-handler-reregistration.test.ts": 49, + "src/main/ipc/ssh-passphrase.test.ts": 6, + "src/main/ipc/ssh-pty-closed-generation-ranges.test.ts": 46, + "src/main/ipc/ssh-pty-consumer-identity.test.ts": 175, + "src/main/ipc/ssh-pty-legacy-projection.test.ts": 18, + "src/main/ipc/ssh-pty-model-admission-generation-scope.test.ts": 16, + "src/main/ipc/ssh-pty-model-admission.test.ts": 15, + "src/main/ipc/ssh-pty-output-exit-deadline.test.ts": 23, + "src/main/ipc/ssh-pty-output-generation-guard.test.ts": 10, + "src/main/ipc/ssh-pty-output-intake.test.ts": 57, + "src/main/ipc/ssh-pty-output-model-migration.test.ts": 25, + "src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts": 20, + "src/main/ipc/ssh-pty-source-ack-coalescer.test.ts": 14, + "src/main/ipc/ssh-pty-source-ack-session-contract.test.ts": 13, + "src/main/ipc/ssh-pty-source-obligation-coordinator.test.ts": 14, + "src/main/ipc/ssh-pty-source-obligation-ledger.test.ts": 82, + "src/main/ipc/ssh-relay-reset-resume.test.ts": 908, + "src/main/ipc/ssh-state-broadcast-fanout.test.ts": 25, + "src/main/ipc/ssh-target-registry.test.ts": 1633, + "src/main/ipc/ssh-terminate-sessions.test.ts": 144, + "src/main/ipc/ssh.test.ts": 61, + "src/main/ipc/telemetry.test.ts": 13, + "src/main/ipc/terminal-git-credential-guard.test.ts": 13, + "src/main/ipc/terminal-preview-output-stream.test.ts": 9, + "src/main/ipc/terminal-preview.test.ts": 155, + "src/main/ipc/terminal-render-desync-evidence.test.ts": 35, + "src/main/ipc/tui-agent-detection-commands.test.ts": 5, + "src/main/ipc/ui.test.ts": 12, + "src/main/ipc/usage-provider-handlers.test.ts": 6, + "src/main/ipc/watched-worktree-catalog-notification.test.ts": 7, + "src/main/ipc/watcher-event-root-path-rewrite.test.ts": 18, + "src/main/ipc/watcher-removal-gate.test.ts": 12, + "src/main/ipc/workspace-cleanup-activity.test.ts": 33, + "src/main/ipc/workspace-cleanup-broad-scan.test.ts": 55, + "src/main/ipc/workspace-cleanup-execution-host-routing.test.ts": 28, + "src/main/ipc/workspace-cleanup-local-git-routing.test.ts": 9, + "src/main/ipc/workspace-cleanup-snapshot-ipc.test.ts": 33, + "src/main/ipc/workspace-cleanup.test.ts": 4276, + "src/main/ipc/workspace-create-error-classifier.test.ts": 10, + "src/main/ipc/workspace-ports.test.ts": 21, + "src/main/ipc/workspace-space.test.ts": 18, + "src/main/ipc/worktree-base-directory-change-collector.test.ts": 8, + "src/main/ipc/worktree-base-directory-event-filter.test.ts": 18, + "src/main/ipc/worktree-base-directory-poller-marker-fanout.test.ts": 142, + "src/main/ipc/worktree-base-directory-poller.test.ts": 2628, + "src/main/ipc/worktree-base-directory-watch-targets.test.ts": 319, + "src/main/ipc/worktree-base-directory-watcher.test.ts": 109, + "src/main/ipc/worktree-create-lineage.test.ts": 16, + "src/main/ipc/worktree-folder-rename-target.test.ts": 8, + "src/main/ipc/worktree-git-common-polling.test.ts": 1088, + "src/main/ipc/worktree-git-common-watch.test.ts": 2677, + "src/main/ipc/worktree-git-status-ref-watch.test.ts": 10, + "src/main/ipc/worktree-head-identity-reader-concurrency.test.ts": 29, + "src/main/ipc/worktree-head-identity-reader-incremental.test.ts": 104, + "src/main/ipc/worktree-head-identity-reader.test.ts": 38, + "src/main/ipc/worktree-head-identity-refresh.test.ts": 28, + "src/main/ipc/worktree-include-copy-budget.test.ts": 53, + "src/main/ipc/worktree-logic-created-agent.test.ts": 5, + "src/main/ipc/worktree-logic-wsl.test.ts": 21, + "src/main/ipc/worktree-logic.test.ts": 53, + "src/main/ipc/worktree-metadata-merge.test.ts": 5, + "src/main/ipc/worktree-path-deduplication.test.ts": 34, + "src/main/ipc/worktree-push-target-cleanup.test.ts": 20, + "src/main/ipc/worktree-push-target-reconciliation-real-git.test.ts": 930, + "src/main/ipc/worktree-push-target-reconciliation.test.ts": 19, + "src/main/ipc/worktree-push-target-refspec-migration.test.ts": 11, + "src/main/ipc/worktree-push-target-refspec-real-git.test.ts": 2112, + "src/main/ipc/worktree-push-target-remote-scan.test.ts": 14, + "src/main/ipc/worktree-push-target-setup.test.ts": 16, + "src/main/ipc/worktree-remote-push-target-materialization.test.ts": 31, + "src/main/ipc/worktree-remote-ssh-branch-conflict.test.ts": 11, + "src/main/ipc/worktree-symlink-reconciliation.real.test.ts": 49, + "src/main/ipc/worktree-symlinks.test.ts": 123, + "src/main/ipc/worktree-watcher-removal-binding.test.ts": 78, + "src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts": 210, + "src/main/ipc/worktrees-create-execution-host-routing.test.ts": 45, + "src/main/ipc/worktrees-create-metadata-persistence.test.ts": 60, + "src/main/ipc/worktrees-delete-pty-teardown.test.ts": 53, + "src/main/ipc/worktrees-detected-scan-cache.test.ts": 97, + "src/main/ipc/worktrees-discovery-metadata-backfill.test.ts": 51, + "src/main/ipc/worktrees-existing-branch-checkout.test.ts": 71, + "src/main/ipc/worktrees-forget-local.test.ts": 39, + "src/main/ipc/worktrees-issue-command-overrides.test.ts": 25, + "src/main/ipc/worktrees-lineage-hydration.test.ts": 33, + "src/main/ipc/worktrees-listing-fallback-rows.test.ts": 39, + "src/main/ipc/worktrees-local-base-ref-resolution.test.ts": 82, + "src/main/ipc/worktrees-local-create-flow.test.ts": 76, + "src/main/ipc/worktrees-orphan-directory-cleanup.test.ts": 35, + "src/main/ipc/worktrees-preserved-branch-fork-remote.test.ts": 28, + "src/main/ipc/worktrees-removal-recovery.test.ts": 75, + "src/main/ipc/worktrees-remove-archive-hooks.test.ts": 35, + "src/main/ipc/worktrees-remove-host-disambiguation.test.ts": 32, + "src/main/ipc/worktrees-remove-preflight.test.ts": 33, + "src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts": 57, + "src/main/ipc/worktrees-ssh-base-ref-resolution.test.ts": 61, + "src/main/ipc/worktrees-ssh-branch-conflict-suffixing.test.ts": 51, + "src/main/ipc/worktrees-ssh-create-base-prefetch.test.ts": 124, + "src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts": 73, + "src/main/ipc/worktrees-ssh-local-base-refresh.test.ts": 86, + "src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts": 43, + "src/main/ipc/worktrees-ssh-provider-authority.test.ts": 35, + "src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts": 42, + "src/main/ipc/worktrees-ssh-setup-launch.test.ts": 48, + "src/main/ipc/worktrees-windows.test.ts": 72, + "src/main/ipc/worktrees-wsl-runtime-routing.test.ts": 84, + "src/main/ipc/worktrees/listing/detected-provider-listing-meta-index.test.ts": 19, + "src/main/ipc/worktrees/listing/detected-scan-failure-authority.test.ts": 29, + "src/main/ipc/worktrees/listing/detected-worktree-classification.test.ts": 21, + "src/main/ipc/worktrees/listing/detected-worktree-scan-hygiene-gate.test.ts": 13, + "src/main/ipc/worktrees/listing/register-sparse-checkout-cache-invalidation.test.ts": 9, + "src/main/jira/adf-markdown.test.ts": 10, + "src/main/jira/attachment-image-cache-generation.test.ts": 11, + "src/main/jira/attachment-image-cache.test.ts": 9, + "src/main/jira/attachment-images.test.ts": 31, + "src/main/jira/client.test.ts": 302, + "src/main/jira/issues.test.ts": 62, + "src/main/jira/jira-issue-mutations.test.ts": 29, + "src/main/jira/jira-issue-summary-timeout.test.ts": 33, + "src/main/jira/jira-search-abort.test.ts": 24, + "src/main/keybindings/keybinding-file.test.ts": 53, + "src/main/keybindings/keybinding-service.test.ts": 39, + "src/main/kimi/hook-service.test.ts": 8, + "src/main/kimi/kimi-hook-config-toml.test.ts": 25, + "src/main/kimi/kimi-runtime-home.test.ts": 15, + "src/main/lib/unread-response-body.test.ts": 51, + "src/main/line-editor-ready-output-scanner.test.ts": 7, + "src/main/linear/client.test.ts": 97, + "src/main/linear/issue-context-client.test.ts": 23, + "src/main/linear/issue-context-current.test.ts": 6, + "src/main/linear/issue-context-errors.test.ts": 6, + "src/main/linear/issue-context-includes.test.ts": 27, + "src/main/linear/issue-context-inline-media.test.ts": 6, + "src/main/linear/issue-context-relations.test.ts": 10, + "src/main/linear/issue-context.test.ts": 16, + "src/main/linear/issue-list-filter.test.ts": 8, + "src/main/linear/issue-relation-write.test.ts": 19, + "src/main/linear/issue-search-summary.test.ts": 6, + "src/main/linear/issues.test.ts": 41, + "src/main/linear/mappers.test.ts": 8, + "src/main/linear/mcp-issue-list-pagination.test.ts": 56, + "src/main/linear/mcp-issue-list.test.ts": 93, + "src/main/linear/projects.test.ts": 306, + "src/main/linear/teams.test.ts": 20, + "src/main/linux-lid-sleep-assertion.test.ts": 14, + "src/main/linux-package-install-command.test.ts": 36, + "src/main/linux-package-install-diagnostic.test.ts": 17, + "src/main/linux-package-update-recovery.test.ts": 178, + "src/main/linux-update-package-type.test.ts": 86, + "src/main/local-builds/local-build-candidate.test.ts": 107, + "src/main/local-builds/local-build-compatibility-contract.test.ts": 6, + "src/main/local-builds/local-build-feed-server.test.ts": 68, + "src/main/local-downloaded-folder-promotion.test.ts": 101, + "src/main/local-worktree-filesystem.test.ts": 269, + "src/main/local-worktree-metadata-prune-gate.test.ts": 9, + "src/main/local-worktree-path-presence.test.ts": 18, + "src/main/local-worktree-removal-recovery.test.ts": 22, + "src/main/localhost-worktree-label-proxy.test.ts": 72, + "src/main/macos-full-disk-access-status.test.ts": 8, + "src/main/macos-press-and-hold-default.test.ts": 13, + "src/main/macos-system-sleep-assertion.test.ts": 20, + "src/main/macos-tcc-prompt-notice.test.ts": 16, + "src/main/macos-tcc-prompt-watch.test.ts": 22, + "src/main/main-process-tree-kill-gate.test.ts": 7, + "src/main/memory/collector-windows-sweep.test.ts": 198, + "src/main/memory/collector.test.ts": 239, + "src/main/memory/host-memory.test.ts": 21, + "src/main/memory/hydrate-local-pty-registry.test.ts": 873, + "src/main/memory/process-memory-metric.test.ts": 6, + "src/main/memory/windows-process-sample-parsing.test.ts": 86, + "src/main/menu/gpu-acceleration-about-panel.test.ts": 9, + "src/main/menu/register-app-menu.test.ts": 21, + "src/main/mimo/hook-service.test.ts": 11, + "src/main/minimax/minimax-api-key-store.test.ts": 30, + "src/main/minimax/minimax-cookie-store.test.ts": 38, + "src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts": 293, + "src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts": 97, + "src/main/native-chat/agent-session-journal/journal-cursor.test.ts": 9, + "src/main/native-chat/agent-session-journal/journal-database.test.ts": 95, + "src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts": 38, + "src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts": 94, + "src/main/native-chat/agent-session-journal/journal-handle-ownership.test.ts": 7709, + "src/main/native-chat/agent-session-journal/journal-item-identity.test.ts": 10, + "src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts": 181, + "src/main/native-chat/agent-session-journal/journal-reducer.test.ts": 45, + "src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts": 28, + "src/main/native-chat/agent-session-journal/journal-row-schema.test.ts": 32, + "src/main/native-chat/agent-session-journal/journal-row-writer.test.ts": 28, + "src/main/native-chat/agent-session-journal/journal-store-close.test.ts": 85, + "src/main/native-chat/agent-session-journal/journal-store-schema.test.ts": 85, + "src/main/native-chat/agent-session-journal/journal-store.test.ts": 290, + "src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts": 835, + "src/main/native-chat/agent-session-wire/agent-session-delta-coalescer-protected.test.ts": 46, + "src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts": 20, + "src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts": 477, + "src/main/native-chat/agent-session-wire/agent-session-history-page-grouping-parity.test.ts": 253, + "src/main/native-chat/agent-session-wire/agent-session-history-page-scaling.test.ts": 17, + "src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts": 1870, + "src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts": 178, + "src/main/native-chat/agent-session-wire/provider-frame-activity.test.ts": 10, + "src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts": 15, + "src/main/native-chat/agent-session-wire/provider-turn-activity-routing.test.ts": 22, + "src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts": 564, + "src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts": 15, + "src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts": 14, + "src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts": 165, + "src/main/native-chat/agent-session-wire/structured-agent-session-claude-options-round-trip.test.ts": 248, + "src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts": 52, + "src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts": 45, + "src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts": 24, + "src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts": 16, + "src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts": 30, + "src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts": 68, + "src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.test.ts": 99, + "src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.test.ts": 144, + "src/main/native-chat/agent-session-wire/structured-agent-session-handoff-options.test.ts": 587, + "src/main/native-chat/agent-session-wire/structured-agent-session-handoff-owner-close.test.ts": 5, + "src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.test.ts": 6, + "src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts": 207, + "src/main/native-chat/agent-session-wire/structured-agent-session-holds.test.ts": 215, + "src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.test.ts": 103, + "src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts": 7, + "src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts": 1194, + "src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts": 66, + "src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts": 832, + "src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.test.ts": 7, + "src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts": 156, + "src/main/native-chat/agent-session-wire/structured-agent-session-live-tui-restart-survival.test.ts": 431, + "src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts": 189, + "src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts": 115, + "src/main/native-chat/agent-session-wire/structured-agent-session-proven-dead-retry.test.ts": 130, + "src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts": 288, + "src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts": 290, + "src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts": 7, + "src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts": 361, + "src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts": 180, + "src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts": 8912, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.test.ts": 6, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore-gate.test.ts": 6, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts": 59, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts": 137, + "src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts": 19, + "src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts": 1652, + "src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts": 34, + "src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts": 20, + "src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts": 515, + "src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts": 304, + "src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts": 13, + "src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts": 581, + "src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts": 314, + "src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts": 1169, + "src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.test.ts": 64, + "src/main/native-chat/agent-session-wire/structured-agent-session-teardown-handoff-drain.test.ts": 251, + "src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts": 47, + "src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts": 15, + "src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts": 985, + "src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts": 569, + "src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts": 6, + "src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts": 831, + "src/main/native-chat/agent-session-wire/structured-conversation-replacements.test.ts": 8, + "src/main/native-chat/agent-session-wire/structured-rewind-claude-proof.test.ts": 9, + "src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts": 16, + "src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts": 21, + "src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.test.ts": 420, + "src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts": 21, + "src/main/native-chat/claude-structured-managed-account-support.test.ts": 11, + "src/main/native-chat/host-readable-transcript-path-fs-gate.test.ts": 213, + "src/main/native-chat/host-readable-transcript-path.test.ts": 20, + "src/main/native-chat/native-chat-file-provenance.test.ts": 10, + "src/main/native-chat/session-file-resolver-claude-roots.test.ts": 6, + "src/main/native-chat/session-file-resolver-codex-roots.test.ts": 4, + "src/main/native-chat/session-file-resolver-wsl-scan-gate.test.ts": 21, + "src/main/native-chat/session-file-resolver-wsl.test.ts": 21, + "src/main/native-chat/session-file-resolver.test.ts": 87, + "src/main/native-chat/structured-agent-session-create-support.test.ts": 8, + "src/main/native-chat/structured-agent-session-history-adoption.test.ts": 9, + "src/main/native-chat/subagent-entry-id-bounds.test.ts": 7, + "src/main/native-chat/transcript-fallback-id.test.ts": 6, + "src/main/native-chat/transcript-interruption-message.test.ts": 7, + "src/main/native-chat/transcript-line-decoders-claude-control-bytes.test.ts": 7, + "src/main/native-chat/transcript-line-decoders-claude-image-companion.test.ts": 6, + "src/main/native-chat/transcript-line-decoders-codex-skill-context.test.ts": 12, + "src/main/native-chat/transcript-line-decoders.grok.test.ts": 13, + "src/main/native-chat/transcript-line-decoders.omp.test.ts": 21, + "src/main/native-chat/transcript-read-cache-refusal-recovery.test.ts": 31, + "src/main/native-chat/transcript-read-cache-wsl-stall.test.ts": 14, + "src/main/native-chat/transcript-read-cache.test.ts": 55, + "src/main/native-chat/transcript-reader-codex-history-mode.test.ts": 21, + "src/main/native-chat/transcript-reader-wsl-stall.test.ts": 20, + "src/main/native-chat/transcript-reader.test.ts": 31, + "src/main/native-chat/transcript-stream-lines.test.ts": 8, + "src/main/native-chat/transcript-tail-reader-cancellation.test.ts": 115, + "src/main/native-chat/transcript-tail-reader-wsl-gate.test.ts": 17, + "src/main/native-chat/transcript-turn-lifecycle.test.ts": 11, + "src/main/native-chat/transcript-watch-engine-wsl-lifecycle.test.ts": 71, + "src/main/native-chat/transcript-watch-error.test.ts": 2068, + "src/main/native-chat/transcript-watch-liveness.test.ts": 639, + "src/main/native-chat/transcript-watch-resolve-poll.test.ts": 82, + "src/main/native-chat/transcript-watch-unflushed-settle.test.ts": 94, + "src/main/native-chat/transcript-watch-unsubscribe-race.test.ts": 26, + "src/main/native-chat/transcript-watch-wsl-exact-path.test.ts": 21, + "src/main/native-chat/transcript-watch-wsl-stall.test.ts": 21, + "src/main/native-chat/transcript-watch.test.ts": 3183, + "src/main/native-chat/transcript-window-tool-attribution.test.ts": 13, + "src/main/native-chat/wsl-codex-session-path-scan.test.ts": 24, + "src/main/native-chat/wsl-transcript-fs-access.test.ts": 29, + "src/main/native-chat/wsl-transcript-fs-gate.test.ts": 463, + "src/main/native-chat/wsl-transcript-fs-process-client.test.ts": 33, + "src/main/native-chat/wsl-transcript-fs-process-operations.test.ts": 5, + "src/main/native-chat/wsl-transcript-fs-route-quarantine.test.ts": 22, + "src/main/native-chat/wsl-transcript-fs-route.test.ts": 6, + "src/main/native-chat/wsl-transcript-running-observer.test.ts": 65, + "src/main/network/electron-proxy-request-guard.test.ts": 314, + "src/main/network/macos-system-resolver-health.test.ts": 18, + "src/main/network/macos-tailscale-dns-diagnostic.test.ts": 8, + "src/main/network/proxy-settings-session.test.ts": 779, + "src/main/network/proxy-settings.test.ts": 67, + "src/main/notifications/desktop-away-state.test.ts": 6, + "src/main/observability/architecture.test.ts": 10, + "src/main/observability/bundle.test.ts": 294, + "src/main/observability/diagnostic-upload-http.test.ts": 8, + "src/main/observability/instrumentation.test.ts": 252, + "src/main/observability/local-file-sink-memory.test.ts": 126, + "src/main/observability/local-file-sink.test.ts": 27, + "src/main/observability/redactor-environment-lines.test.ts": 486, + "src/main/observability/redactor.test.ts": 27, + "src/main/observability/tracer.test.ts": 27, + "src/main/opencode-usage/scanner-windows-data-directory.test.ts": 23, + "src/main/opencode-usage/scanner-wsl-gate.test.ts": 18, + "src/main/opencode-usage/scanner.test.ts": 224, + "src/main/opencode-usage/store.test.ts": 28, + "src/main/opencode/hook-plugin-background-child-completion.test.ts": 197, + "src/main/opencode/hook-plugin-child-attention.test.ts": 421, + "src/main/opencode/hook-plugin-fail-open-ownership.test.ts": 460, + "src/main/opencode/hook-plugin-lifecycle-delivery.test.ts": 591, + "src/main/opencode/hook-plugin-message-part-throttle.test.ts": 137, + "src/main/opencode/hook-plugin-module-contract.test.ts": 146, + "src/main/opencode/hook-service.test.ts": 50, + "src/main/opencode/opencode-data-directory.test.ts": 6, + "src/main/orca-profiles/profile-artifact-cloud-cleanup.test.ts": 142, + "src/main/orca-profiles/profile-cloud-auth-config.test.ts": 7, + "src/main/orca-profiles/profile-cloud-auth-status.test.ts": 7, + "src/main/orca-profiles/profile-cloud-client.test.ts": 73, + "src/main/orca-profiles/profile-cloud-dev-service.test.ts": 52, + "src/main/orca-profiles/profile-cloud-org-members-client.test.ts": 9, + "src/main/orca-profiles/profile-cloud-org-members-service.test.ts": 33, + "src/main/orca-profiles/profile-cloud-pkce.test.ts": 184, + "src/main/orca-profiles/profile-cloud-service-auth-retry.test.ts": 42, + "src/main/orca-profiles/profile-cloud-service-refresh.test.ts": 148, + "src/main/orca-profiles/profile-cloud-service.test.ts": 45, + "src/main/orca-profiles/profile-cloud-session-mutation.test.ts": 14, + "src/main/orca-profiles/profile-cloud-session-refresh.test.ts": 12, + "src/main/orca-profiles/profile-cloud-session-store.test.ts": 71, + "src/main/orca-profiles/profile-index-store.test.ts": 188, + "src/main/orca-profiles/profile-project-presence.test.ts": 59, + "src/main/orca-profiles/profile-project-session-field-disposition.test.ts": 9, + "src/main/orca-profiles/profile-project-session-state.test.ts": 13, + "src/main/orca-profiles/profile-project-state-file.test.ts": 7, + "src/main/orca-profiles/profile-project-transfer.test.ts": 238, + "src/main/orca-profiles/profile-ui-scope.test.ts": 4, + "src/main/orcad/electron-serve-browser-process.test.ts": 2342, + "src/main/orcad/electron-serve-provider-selection.test.ts": 427, + "src/main/orcad/electron-sidecar-method-routing.test.ts": 7, + "src/main/orcad/electron-sidecar-tab-registry.test.ts": 12, + "src/main/orcad/external-chromium-browser-session.test.ts": 16, + "src/main/orcad/main-preflight-order.test.ts": 8, + "src/main/orcad/native-host-abi.test.ts": 15, + "src/main/orcad/node-pty-prebuilt-slot.test.ts": 12, + "src/main/orcad/node-pty-precondition.test.ts": 290, + "src/main/orcad/orcad-app-paths.test.ts": 10, + "src/main/orcad/orcad-bind-address.test.ts": 11, + "src/main/orcad/orcad-browser-provider.test.ts": 53, + "src/main/orcad/orcad-bundle-native-load-order.test.ts": 3283, + "src/main/orcad/orcad-daemon-supervision.test.ts": 10, + "src/main/orcad/orcad-forked-child-paths.test.ts": 8, + "src/main/orcad/orcad-health.test.ts": 10, + "src/main/orcad/orcad-instance-lock.test.ts": 15, + "src/main/orcad/orcad-launch-contract.test.ts": 13, + "src/main/orcad/orcad-native-preflight.test.ts": 6, + "src/main/orcad/orcad-push-startup.test.ts": 704, + "src/main/own-chromium-tree-kill-guard.test.ts": 20, + "src/main/persistence-async-write-syscalls.test.ts": 5455, + "src/main/persistence-automations.test.ts": 593, + "src/main/persistence-clipboard-selection-migration.test.ts": 65, + "src/main/persistence-cohort-and-identity-migration.test.ts": 67, + "src/main/persistence-cross-host-pane-identity.test.ts": 36, + "src/main/persistence-deregistered-repo-residue.test.ts": 132, + "src/main/persistence-duplicate-repo-id-host-scope.test.ts": 792, + "src/main/persistence-feature-interaction-broadcast.benchmark.test.ts": 126, + "src/main/persistence-floating-terminal-trust.test.ts": 91, + "src/main/persistence-flush-and-save-scheduling.test.ts": 226, + "src/main/persistence-folder-workspace-notes.test.ts": 124, + "src/main/persistence-host-admitted-terminal-membership.test.ts": 32, + "src/main/persistence-host-partitioned-sessions.test.ts": 173, + "src/main/persistence-host-partitioned-ssh-pty-bindings.test.ts": 31, + "src/main/persistence-initial-load.test.ts": 171, + "src/main/persistence-layout-binding-recovery.test.ts": 46, + "src/main/persistence-load-repair-durability.test.ts": 65, + "src/main/persistence-loading-store-extraction.test.ts": 93, + "src/main/persistence-loading-store-write-risks.test.ts": 34, + "src/main/persistence-native-chat-tab-view-mode.test.ts": 17, + "src/main/persistence-pane-identity-migration.test.ts": 27776, + "src/main/persistence-protected-secret-fail-closed.test.ts": 2755, + "src/main/persistence-protected-secret-write-race.test.ts": 698, + "src/main/persistence-proxy-secret-recovery.test.ts": 1089, + "src/main/persistence-pty-binding-leaf-tab-resolution.test.ts": 33, + "src/main/persistence-pty-binding-reconciliation.test.ts": 54, + "src/main/persistence-remote-session-startup.test.ts": 58, + "src/main/persistence-repo-lifecycle.test.ts": 807, + "src/main/persistence-right-sidebar-tab.test.ts": 7, + "src/main/persistence-settings-ui-defaults.test.ts": 137, + "src/main/persistence-settings-update.test.ts": 124, + "src/main/persistence-single-serialize.test.ts": 1727, + "src/main/persistence-source-control-ai-migration.test.ts": 108, + "src/main/persistence-split-pane-incarnation.test.ts": 65, + "src/main/persistence-ssh-lease-reattach-reclaim.test.ts": 46, + "src/main/persistence-ssh-lease-tombstone-retention.test.ts": 44, + "src/main/persistence-ssh-pending-pty-kill.test.ts": 570, + "src/main/persistence-ssh-readoption-automation-migration.test.ts": 1520, + "src/main/persistence-ssh-remote-pty-binding-replay.test.ts": 41, + "src/main/persistence-ssh-remote-pty-leases.test.ts": 129, + "src/main/persistence-ssh-targets-and-pane-keys.test.ts": 72, + "src/main/persistence-terminal-option-key-migration.test.ts": 47, + "src/main/persistence-ui-state.test.ts": 3109, + "src/main/persistence-update-repo.test.ts": 252, + "src/main/persistence-workspace-pinned-automation-fence.test.ts": 1660, + "src/main/persistence-workspace-repin-automation-owner.test.ts": 1100, + "src/main/persistence-workspace-session-scrollback.test.ts": 101, + "src/main/persistence-workspace-status-workflow.test.ts": 67, + "src/main/persistence-worktree-card-properties.test.ts": 43, + "src/main/persistence-worktree-deletion-fencing.test.ts": 88, + "src/main/persistence-worktree-lineage-and-backups.test.ts": 499, + "src/main/persistence-worktree-meta-and-folder-workspaces.test.ts": 83, + "src/main/persistence-worktree-name-retirement.test.ts": 3387, + "src/main/persistence-worktree-visibility.test.ts": 100, + "src/main/persistence/applying-settings/settings-update-terminal-contrast.test.ts": 8, + "src/main/persistence/applying-settings/terminal-settings-migrations.test.ts": 4, + "src/main/persistence/leasing-ssh-ptys/ssh-pty-binding-cleanup.test.ts": 22, + "src/main/persistence/loading-store/metadata-lineage-batch-pruning.test.ts": 46, + "src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts": 17, + "src/main/persistence/loading-store/normalize-loaded-project-catalog.test.ts": 12, + "src/main/persistence/loading-store/persisted-state-redundancy.test.ts": 461, + "src/main/persistence/loading-store/secret-sentinel-substitution.test.ts": 14, + "src/main/persistence/loading-store/state-write-round-trip.test.ts": 72, + "src/main/persistence/loading-store/store-prune-gate-signals.test.ts": 32, + "src/main/persistence/loading-store/store-runtime-authored-session-writes.test.ts": 29, + "src/main/persistence/loading-store/workspace-session-partitions.test.ts": 16, + "src/main/persistence/loading-store/workspace-session-terminal-binding-replay.test.ts": 13, + "src/main/persistence/loading-store/worktree-meta-alias-projection.test.ts": 345, + "src/main/persistence/loading-store/worktree-meta-write-normalization.test.ts": 8, + "src/main/persistence/restoring-sessions/pane-alias-normalization-scaling.test.ts": 11, + "src/main/persistence/restoring-sessions/pane-alias-normalization.test.ts": 6, + "src/main/persistence/restoring-sessions/pane-key-remapping.test.ts": 9, + "src/main/persistence/restoring-sessions/session-owner-fields.test.ts": 10, + "src/main/persistence/restoring-sessions/session-worktree-ownership.test.ts": 21, + "src/main/persistence/restoring-sessions/terminal-layout-normalization.test.ts": 6, + "src/main/persistence/restoring-sessions/workspace-pane-normalization-index.test.ts": 10, + "src/main/persistence/runtime-authored-workspace-session-fields.test.ts": 5, + "src/main/persistence/scheduling-automations/automation-run-operations.test.ts": 16, + "src/main/persistence/tracking-repos/local-worktree-metadata-scan-expectation.test.ts": 21, + "src/main/persistence/tracking-repos/missing-local-worktree-metadata-pruning.test.ts": 107, + "src/main/persistence/tracking-repos/probeable-local-worktree-metadata-candidates.test.ts": 13, + "src/main/persistence/tracking-repos/project-host-compatibility.test.ts": 11, + "src/main/persistence/tracking-repos/worktree-metadata-normalization.test.ts": 9, + "src/main/pi/agent-status-extension-omp-lifecycle.test.ts": 839, + "src/main/pi/agent-status-extension-source.test.ts": 1483, + "src/main/pi/agent-status-omp-approval-forwarding.test.ts": 487, + "src/main/pi/agent-status-owner-recovery.test.ts": 432, + "src/main/pi/agent-status-ui-prompt.test.ts": 623, + "src/main/pi/titlebar-extension-overlay-path.test.ts": 9, + "src/main/pi/titlebar-extension-service.test.ts": 147, + "src/main/pi/titlebar-extension-source.test.ts": 618, + "src/main/plugins/plugin-atomic-file-write.test.ts": 1988, + "src/main/plugins/plugin-audit-log-scaling.test.ts": 412, + "src/main/plugins/plugin-audit-log.test.ts": 17, + "src/main/plugins/plugin-bundled-bootstrap-coordinator.test.ts": 62, + "src/main/plugins/plugin-bundled-bootstrap.test.ts": 104, + "src/main/plugins/plugin-command-registry.test.ts": 32, + "src/main/plugins/plugin-content-pack-registry.test.ts": 30, + "src/main/plugins/plugin-content-safety.test.ts": 66, + "src/main/plugins/plugin-dev-watcher.test.ts": 106, + "src/main/plugins/plugin-discovery.test.ts": 40, + "src/main/plugins/plugin-enablement.test.ts": 14, + "src/main/plugins/plugin-host-conformance.test.ts": 30, + "src/main/plugins/plugin-host-methods.test.ts": 22, + "src/main/plugins/plugin-host-process.test.ts": 13, + "src/main/plugins/plugin-host-runtime.test.ts": 11, + "src/main/plugins/plugin-install-trust.test.ts": 51, + "src/main/plugins/plugin-install.test.ts": 334, + "src/main/plugins/plugin-kill-list-content-revocation.test.ts": 52, + "src/main/plugins/plugin-kill-list-service.test.ts": 68, + "src/main/plugins/plugin-language-pack-registry.test.ts": 22, + "src/main/plugins/plugin-launch-content.test.ts": 84, + "src/main/plugins/plugin-list-projection.test.ts": 15, + "src/main/plugins/plugin-marketplace-installer.test.ts": 98, + "src/main/plugins/plugin-marketplace-service.test.ts": 136, + "src/main/plugins/plugin-marketplace-store.test.ts": 241, + "src/main/plugins/plugin-panel-controller.test.ts": 25, + "src/main/plugins/plugin-panel-navigation-guard.test.ts": 7, + "src/main/plugins/plugin-panel-owner-lifecycle.test.ts": 4, + "src/main/plugins/plugin-panel-sessions.test.ts": 7, + "src/main/plugins/plugin-private-marketplace.integration.test.ts": 321, + "src/main/plugins/plugin-secrets-store.test.ts": 33, + "src/main/plugins/plugin-service-integrity.test.ts": 70, + "src/main/plugins/plugin-service-reconciliation.test.ts": 314, + "src/main/plugins/plugin-startup-budget.test.ts": 373, + "src/main/plugins/plugin-storage-store.test.ts": 8, + "src/main/plugins/plugin-vm-recipe-registry.test.ts": 40, + "src/main/plugins/plugin-worker-controller.test.ts": 77, + "src/main/plugins/plugin-worker-env.test.ts": 6, + "src/main/plugins/plugin-worker-manager.test.ts": 17, + "src/main/plugins/plugin-worker-output-buffer.test.ts": 12, + "src/main/plugins/plugin-worker-supervision.integration.test.ts": 8191, + "src/main/ports/advertised-url-watcher-pid-validation.test.ts": 10, + "src/main/ports/advertised-url-watcher.test.ts": 22, + "src/main/ports/local-workspace-port-scanner.test.ts": 44, + "src/main/ports/port-scan-command-client.test.ts": 1741, + "src/main/ports/port-scan-command-execution.test.ts": 5223, + "src/main/ports/port-scan-command-import-boundary.test.ts": 6, + "src/main/ports/ssh-advertised-url-enrichment.test.ts": 13, + "src/main/ports/workspace-port-ownership.test.ts": 9, + "src/main/powershell-osc133-bootstrap.test.ts": 14, + "src/main/project-groups/folder-workspace-path-status.test.ts": 19, + "src/main/project-groups/nested-repo-discovery.test.ts": 103, + "src/main/project-groups/nested-repo-import-target.test.ts": 10, + "src/main/project-groups/nested-repo-import.test.ts": 17, + "src/main/project-runtime-git-options.test.ts": 15, + "src/main/protected-secret-persistence.test.ts": 20, + "src/main/providers/agent-foreground-process-batch.test.ts": 15, + "src/main/providers/agent-foreground-process-pi.test.ts": 12, + "src/main/providers/agent-foreground-process-ps-scan-volume.test.ts": 24, + "src/main/providers/agent-foreground-process-real-rows.test.ts": 13, + "src/main/providers/agent-foreground-process-remote-evidence.test.ts": 11, + "src/main/providers/agent-foreground-process-windows-job-rejection.test.ts": 4, + "src/main/providers/agent-foreground-process.test.ts": 30, + "src/main/providers/execution-host-provider-dispatch.test.ts": 12, + "src/main/providers/local-pty-foreground-inspection-cheap-tier.test.ts": 10, + "src/main/providers/local-pty-provider-foreground-process.test.ts": 110, + "src/main/providers/local-pty-provider-io-events.test.ts": 31, + "src/main/providers/local-pty-provider-session-inventory.test.ts": 29, + "src/main/providers/local-pty-provider-shell-readiness.test.ts": 39, + "src/main/providers/local-pty-provider-shutdown.test.ts": 351, + "src/main/providers/local-pty-provider-spawn-cwd-safety.test.ts": 21, + "src/main/providers/local-pty-provider-spawn-env.test.ts": 40, + "src/main/providers/local-pty-provider-spawn-session.test.ts": 33, + "src/main/providers/local-pty-provider-windows-shell-launch.test.ts": 72, + "src/main/providers/local-pty-shell-ready-marker-scan.test.ts": 8, + "src/main/providers/local-pty-shell-ready-startup-command.test.ts": 11, + "src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts": 411, + "src/main/providers/local-pty-shell-startup-command.node-pty.test.ts": 52, + "src/main/providers/local-pty-utils-windows-fallback.test.ts": 9, + "src/main/providers/local-pty-utils.test.ts": 12, + "src/main/providers/macos-login-session-pty-probe.test.ts": 12, + "src/main/providers/macos-tcc-login-shell.test.ts": 77, + "src/main/providers/posix-pane-foreground-fingerprint.test.ts": 9, + "src/main/providers/process-cwd.test.ts": 100, + "src/main/providers/provider-dispatch.test.ts": 38, + "src/main/providers/pty-default-cwd.test.ts": 9, + "src/main/providers/pty-process-inspection.test.ts": 15, + "src/main/providers/pty-process-list-admission.test.ts": 76, + "src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts": 1855, + "src/main/providers/settled-pty-writer-census.test.ts": 79, + "src/main/providers/ssh-filesystem-dispatch.test.ts": 9, + "src/main/providers/ssh-filesystem-doc-preview.test.ts": 7, + "src/main/providers/ssh-filesystem-download.test.ts": 145, + "src/main/providers/ssh-filesystem-provider-capabilities.test.ts": 15, + "src/main/providers/ssh-filesystem-provider-download-folder.test.ts": 22, + "src/main/providers/ssh-filesystem-provider-range.test.ts": 13, + "src/main/providers/ssh-filesystem-provider-stream.test.ts": 52, + "src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts": 437, + "src/main/providers/ssh-filesystem-provider.test.ts": 59, + "src/main/providers/ssh-filesystem-watch-notifications.test.ts": 11, + "src/main/providers/ssh-git-dispatch.test.ts": 4, + "src/main/providers/ssh-git-provider-api.test.ts": 8, + "src/main/providers/ssh-git-provider-commit-message.test.ts": 18, + "src/main/providers/ssh-git-provider-diff.test.ts": 32, + "src/main/providers/ssh-git-provider-exec.test.ts": 26, + "src/main/providers/ssh-git-provider-merge.test.ts": 9, + "src/main/providers/ssh-git-provider-remote-sync.test.ts": 21, + "src/main/providers/ssh-git-provider-staging.test.ts": 16, + "src/main/providers/ssh-git-provider-status-lease.test.ts": 24, + "src/main/providers/ssh-git-provider-status.test.ts": 22, + "src/main/providers/ssh-git-provider-upstream-lease.test.ts": 16, + "src/main/providers/ssh-git-provider-worktree.test.ts": 29, + "src/main/providers/ssh-git-worktree-list-dedupe.test.ts": 32, + "src/main/providers/ssh-pty-inspect-observation-identity.test.ts": 6, + "src/main/providers/ssh-pty-live-source-restore-respawn.test.ts": 15, + "src/main/providers/ssh-pty-notification-rejection-routing.test.ts": 162, + "src/main/providers/ssh-pty-notification-routing.test.ts": 22, + "src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts": 35, + "src/main/providers/ssh-pty-provider-claim-incarnation.test.ts": 7, + "src/main/providers/ssh-pty-provider-exit-race.test.ts": 19, + "src/main/providers/ssh-pty-provider-process-events.test.ts": 18, + "src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts": 9, + "src/main/providers/ssh-pty-provider-spawn.test.ts": 33, + "src/main/providers/ssh-pty-provider-terminal-repair.test.ts": 36, + "src/main/providers/ssh-pty-provider.test.ts": 31, + "src/main/providers/ssh-pty-reattach-absence-discrimination.test.ts": 23, + "src/main/providers/ssh-pty-relay-absence-verdict.test.ts": 19, + "src/main/providers/ssh-pty-source-delivery-ledger.test.ts": 14, + "src/main/providers/ssh-pty-write.test.ts": 25, + "src/main/providers/ssh-worktree-catalog-authority.test.ts": 29, + "src/main/providers/stable-foreground-process.test.ts": 5, + "src/main/providers/windows-agent-foreground-process-scan-volume.test.ts": 37, + "src/main/providers/windows-cached-agent-revalidation.test.ts": 11, + "src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts": 7, + "src/main/providers/windows-console-attached-processes.test.ts": 16, + "src/main/providers/windows-foreground-process-inspection-cost.test.ts": 15, + "src/main/providers/windows-foreground-process-rows.test.ts": 13, + "src/main/providers/windows-powershell-executable.test.ts": 18, + "src/main/providers/windows-powershell.test.ts": 8, + "src/main/providers/windows-pty-job-membership.test.ts": 13, + "src/main/providers/windows-shell-args.test.ts": 48, + "src/main/providers/windows-shell-fallback-chain.test.ts": 10, + "src/main/providers/working-directory-validation.test.ts": 45, + "src/main/provisioned-root-ssh-adoption.test.ts": 140, + "src/main/proxy-guarded-fetch-call-site-audit.test.ts": 76, + "src/main/pty-descendant-termination-job-coverage.test.ts": 5, + "src/main/pty-descendant-termination.test.ts": 59, + "src/main/pty/appimage-terminal-env.test.ts": 6, + "src/main/pty/build-mode-env.test.ts": 6, + "src/main/pty/codex-home-wsl-env.test.ts": 5, + "src/main/pty/codex-preflight-profile-path-rewrite.test.ts": 134, + "src/main/pty/codex-shell-launch-preflight.test.ts": 4086, + "src/main/pty/conda-activation-env.test.ts": 8, + "src/main/pty/legacy-terminal-shim-dir.test.ts": 1197, + "src/main/pty/legacy-terminal-windows-tombstone.test.ts": 21, + "src/main/pty/node-pty-master-fd-retirement.test.ts": 5158, + "src/main/pty/node-pty-self-exit-pseudoconsole-close.test.ts": 6, + "src/main/pty/omp-shell-wrapper-alias-safety.test.ts": 16, + "src/main/pty/omp-sqlite-overlay.test.ts": 5, + "src/main/pty/overlay-mirror.test.ts": 17, + "src/main/pty/posix-pty-foreground-group.test.ts": 9, + "src/main/pty/posix-pty-process-groups.integration.test.ts": 106, + "src/main/pty/posix-pty-process-groups.test.ts": 10, + "src/main/pty/shell-startup-env.test.ts": 19, + "src/main/pty/terminal-color-env.test.ts": 8, + "src/main/pty/windows-environment-path-main-loop.test.ts": 367, + "src/main/pty/windows-environment-path.test.ts": 17, + "src/main/pty/windows-path-registry-change.test.ts": 6, + "src/main/pty/windows-path-registry-fallback.test.ts": 7, + "src/main/pty/windows-path-registry-reader.test.ts": 9, + "src/main/pty/wsl-orca-env.test.ts": 20, + "src/main/pwsh.test.ts": 24, + "src/main/quit-path-durable-write-blocking.test.ts": 1453, + "src/main/quit-teardown-agent-browser-daemons.test.ts": 6, + "src/main/quit-teardown-deadline.test.ts": 11, + "src/main/quit-teardown-start-gate.test.ts": 8, + "src/main/rate-limits/account-runtime-target-sync.test.ts": 12, + "src/main/rate-limits/antigravity-usage-mirror.test.ts": 7, + "src/main/rate-limits/auth-filesystem-operation.test.ts": 11, + "src/main/rate-limits/claude-fetcher-cli-fallback.test.ts": 86, + "src/main/rate-limits/claude-fetcher-fable-usage.test.ts": 65, + "src/main/rate-limits/claude-fetcher-keychain-credentials.test.ts": 100, + "src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts": 50, + "src/main/rate-limits/claude-oauth-usage-error.test.ts": 44, + "src/main/rate-limits/claude-pty.test.ts": 64, + "src/main/rate-limits/claude-usage-error-classification.test.ts": 9, + "src/main/rate-limits/claude-usage-refresh-plan.test.ts": 7, + "src/main/rate-limits/codex-auth-presence.test.ts": 17, + "src/main/rate-limits/codex-fetcher-auth-errors.test.ts": 15, + "src/main/rate-limits/codex-fetcher-backend.test.ts": 76, + "src/main/rate-limits/codex-fetcher-probe-shutdown.test.ts": 38, + "src/main/rate-limits/codex-fetcher-process-contract.test.ts": 43, + "src/main/rate-limits/codex-fetcher-pty-settle.test.ts": 38, + "src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts": 175, + "src/main/rate-limits/codex-fetcher-runtime-pairing.test.ts": 17, + "src/main/rate-limits/codex-fetcher-session-supplement.test.ts": 20, + "src/main/rate-limits/codex-fetcher.test.ts": 86, + "src/main/rate-limits/codex-probe-termination.test.ts": 16, + "src/main/rate-limits/codex-pty-rate-limit-probe.test.ts": 10, + "src/main/rate-limits/codex-pty-status-parser.test.ts": 7, + "src/main/rate-limits/codex-rate-limit-window-classification.test.ts": 11, + "src/main/rate-limits/codex-rpc-rate-limit-probe.test.ts": 60, + "src/main/rate-limits/gemini-bucket-formatting.test.ts": 6, + "src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts": 10, + "src/main/rate-limits/gemini-usage-fetcher.test.ts": 19, + "src/main/rate-limits/grok-auth.test.ts": 41, + "src/main/rate-limits/grok-fetcher.test.ts": 27, + "src/main/rate-limits/hidden-pty-cleanup.test.ts": 12, + "src/main/rate-limits/initial-account-rate-limit-target.test.ts": 14, + "src/main/rate-limits/kimi-fetcher-wsl-home.test.ts": 12, + "src/main/rate-limits/kimi-fetcher.test.ts": 34, + "src/main/rate-limits/minimax/minimax-fetcher.test.ts": 46, + "src/main/rate-limits/minimax/minimax-request-context.test.ts": 26, + "src/main/rate-limits/opencode-go-usage-fetcher.test.ts": 34, + "src/main/rate-limits/service-account-target-selection.test.ts": 142, + "src/main/rate-limits/service-antigravity-usage.test.ts": 11, + "src/main/rate-limits/service-inactive-account-previews.test.ts": 27, + "src/main/rate-limits/service-live-claude-usage.test.ts": 37, + "src/main/rate-limits/service-minimax-usage.test.ts": 17, + "src/main/rate-limits/service-refresh-orchestration.test.ts": 29, + "src/main/rate-limits/service-window-activation.test.ts": 22, + "src/main/refused-tree-kill-root-termination.test.ts": 24, + "src/main/remote-agent-trust-presets.test.ts": 13, + "src/main/remote-worktree-history-cleanup.test.ts": 12, + "src/main/repo-git-remote-identity-enrichment.test.ts": 22, + "src/main/repo-git-remote-identity.test.ts": 23, + "src/main/repo-git-username-enrichment.test.ts": 11, + "src/main/repo-icon-autodetect.test.ts": 242, + "src/main/repo-icon-file-detection.test.ts": 70, + "src/main/repo-icon-source-href.test.ts": 73, + "src/main/repo-maintenance-idle-gate.test.ts": 5, + "src/main/repo-worktrees.test.ts": 20, + "src/main/runtime/agent-prompt-receipt-correlation.test.ts": 65, + "src/main/runtime/agent-prompt-request-correlation.test.ts": 9, + "src/main/runtime/agent-prompt-submission-runtime.test.ts": 1762, + "src/main/runtime/agent-prompt-submission-verification.test.ts": 125, + "src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts": 192, + "src/main/runtime/agent-session-backup-recovery.test.ts": 116, + "src/main/runtime/agent-session-claim-identity.test.ts": 12, + "src/main/runtime/agent-session-conversation-name-store.test.ts": 76, + "src/main/runtime/agent-session-eviction-settlement-latch.test.ts": 9, + "src/main/runtime/agent-session-handoff-lease-transitions.test.ts": 8, + "src/main/runtime/agent-session-launch-env-admission.test.ts": 165, + "src/main/runtime/agent-session-launch-env-backfill.test.ts": 40, + "src/main/runtime/agent-session-lease-renewal.test.ts": 221, + "src/main/runtime/agent-session-orphan-child-reaper.test.ts": 12, + "src/main/runtime/agent-session-process-identity-probe-windows-batch.test.ts": 5, + "src/main/runtime/agent-session-process-identity-probe.test.ts": 17, + "src/main/runtime/agent-session-provider-handle-transition.test.ts": 7, + "src/main/runtime/agent-session-pty-write-enforcement.test.ts": 2113, + "src/main/runtime/agent-session-pty-write-gate.test.ts": 15, + "src/main/runtime/agent-session-record-conversation-name.test.ts": 14, + "src/main/runtime/agent-session-record-options.test.ts": 33, + "src/main/runtime/agent-session-record-store-security.test.ts": 23, + "src/main/runtime/agent-session-record-store.test.ts": 744, + "src/main/runtime/agent-session-record-unsupported-schema.test.ts": 26, + "src/main/runtime/agent-session-recovery-publish-fault.test.ts": 25, + "src/main/runtime/agent-session-reservation-admission.test.ts": 9, + "src/main/runtime/agent-session-restart-handoff-adjudication.test.ts": 13, + "src/main/runtime/agent-session-resume-args.test.ts": 5, + "src/main/runtime/agent-session-spawn-token-process-scan.test.ts": 8, + "src/main/runtime/agent-session-spawn-token-readback.test.ts": 9, + "src/main/runtime/agent-session-unreadable-record-salvage.test.ts": 68, + "src/main/runtime/agent-terminal-launch-trust-host.test.ts": 27, + "src/main/runtime/antigravity-readiness-transcripts.test.ts": 9380, + "src/main/runtime/automation-change-publication.test.ts": 4436, + "src/main/runtime/browser-client-download-transfers.test.ts": 80, + "src/main/runtime/browser-execution-host-key-resolution.test.ts": 25, + "src/main/runtime/browser-host-capability-selection.test.ts": 12, + "src/main/runtime/browser-host-client-page-adoption-grants.test.ts": 9, + "src/main/runtime/browser-host-client-page-adoption.test.ts": 9, + "src/main/runtime/browser-host-client-page-creation.test.ts": 28, + "src/main/runtime/browser-host-command-ledger-capacity.test.ts": 14, + "src/main/runtime/browser-host-command-ledger.test.ts": 30, + "src/main/runtime/browser-host-file-channel-admission.test.ts": 7, + "src/main/runtime/browser-host-lease-download-transfer-cleanup.test.ts": 114, + "src/main/runtime/browser-host-lease-fenced-page-release.test.ts": 30, + "src/main/runtime/browser-host-lease-placement-retirement.test.ts": 33, + "src/main/runtime/browser-host-lease-registry.test.ts": 47, + "src/main/runtime/browser-host-page-placement-replacement.test.ts": 11, + "src/main/runtime/browser-host-page-placement.test.ts": 16, + "src/main/runtime/browser-host-page-reconciliation-executor.test.ts": 44, + "src/main/runtime/browser-host-page-reconciliation-orchestration.test.ts": 245, + "src/main/runtime/browser-host-page-reconciliation-plan.test.ts": 22, + "src/main/runtime/browser-host-page-reconciliation-preconditions.test.ts": 7, + "src/main/runtime/browser-host-page-retirement.test.ts": 9, + "src/main/runtime/browser-network-tunnel-paired-runtime.integration.test.ts": 411, + "src/main/runtime/browser-screencast-driver-attribution.test.ts": 503, + "src/main/runtime/browser-screencast-driver-scope.test.ts": 5, + "src/main/runtime/browser-screencast-ghost-subscriber-eviction.test.ts": 14, + "src/main/runtime/browser-screencast-remote-viewer-retention.test.ts": 379, + "src/main/runtime/browser-session-tab-selection-snapshot.test.ts": 12, + "src/main/runtime/browser-tab-create-caller-navigation.test.ts": 641, + "src/main/runtime/browser-tab-create-publication.test.ts": 998, + "src/main/runtime/claude-agent-teams-pty-exit-leak.test.ts": 23, + "src/main/runtime/claude-agent-teams-service.test.ts": 29, + "src/main/runtime/claude-agent-teams-shim-env.test.ts": 41, + "src/main/runtime/claude-structured-session-integration.test.ts": 943, + "src/main/runtime/cli-terminal-create-host-session-binding.test.ts": 22, + "src/main/runtime/client-hosted-browser-page-persistence.integration.test.ts": 39, + "src/main/runtime/client-hosted-browser-page-persistence.test.ts": 24, + "src/main/runtime/client-hosted-browser-row-hydration-census.test.ts": 427, + "src/main/runtime/client-hosted-browser-row-projection.test.ts": 9, + "src/main/runtime/client-hosted-browser-row-publication.test.ts": 30, + "src/main/runtime/client-hosted-browser-row-push.integration.test.ts": 41, + "src/main/runtime/client-hosted-page-reconciliation-hold.integration.test.ts": 16, + "src/main/runtime/client-hosted-page-reconciliation-window.test.ts": 23, + "src/main/runtime/client-session-tab-selection.test.ts": 11, + "src/main/runtime/decorative-title-fact-emission.test.ts": 4, + "src/main/runtime/exit-provenance-audit.test.ts": 394, + "src/main/runtime/expired-ssh-lease-pane-candidacy.test.ts": 22, + "src/main/runtime/external-worktree-paired-client-discovery.integration.test.ts": 203, + "src/main/runtime/fetch-remote-cache.test.ts": 271, + "src/main/runtime/file-watcher-host.test.ts": 600, + "src/main/runtime/fit-override-integration.test.ts": 56, + "src/main/runtime/folder-workspace-pty-identity.test.ts": 54, + "src/main/runtime/graph-sync-deletion-fence.test.ts": 43, + "src/main/runtime/graph-sync-live-daemon-pty-tab-preservation.test.ts": 95, + "src/main/runtime/graph-sync-mobile-snapshot-gating.test.ts": 94, + "src/main/runtime/graph-sync-payload-partition.test.ts": 33, + "src/main/runtime/headless-tab-group-split-layout.test.ts": 15, + "src/main/runtime/headless-tab-order-stability.test.ts": 29, + "src/main/runtime/headless-terminal-dispose-write-ordering.test.ts": 25, + "src/main/runtime/headless-terminal-query-reply-policy.test.ts": 4, + "src/main/runtime/headless-terminal-split-layout.test.ts": 9, + "src/main/runtime/hidden-output-restored-provider-snapshot.test.ts": 74, + "src/main/runtime/host-terminal-close-persistence-durability.test.ts": 59, + "src/main/runtime/linear-save-issue.test.ts": 34, + "src/main/runtime/managed-worktree-create-execution-host.test.ts": 24, + "src/main/runtime/missing-worktree-terminal-reconciliation.test.ts": 14, + "src/main/runtime/mobile-agent-status-permission-renewal.test.ts": 34, + "src/main/runtime/mobile-notification-dismissal-read-failure.test.ts": 4, + "src/main/runtime/mobile-notification-dismissal-store.test.ts": 12, + "src/main/runtime/mobile-notification-replay.test.ts": 11, + "src/main/runtime/mobile-pairing-qr.test.ts": 1086, + "src/main/runtime/mobile-pairing-userdata-path.test.ts": 1112, + "src/main/runtime/mobile-presence-lock.test.ts": 230, + "src/main/runtime/mobile-rpc-allowlist.test.ts": 84, + "src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts": 21, + "src/main/runtime/mobile-session-tabs-churn-coalescing.test.ts": 26, + "src/main/runtime/mobile-session-tabs-notify-coalescer.test.ts": 17, + "src/main/runtime/mobile-session-terminal-persistence-retirement.test.ts": 19, + "src/main/runtime/mobile-session-terminal-retirement-proof.test.ts": 5, + "src/main/runtime/mobile-session-terminal-retirement.test.ts": 12, + "src/main/runtime/mobile-subscribe-integration.test.ts": 303, + "src/main/runtime/multi-client-navigation-isolation.integration.test.ts": 551, + "src/main/runtime/opencode-finished-session-authority.test.ts": 356, + "src/main/runtime/orca-runtime-agent-session-operation.test.ts": 84, + "src/main/runtime/orca-runtime-agent-skill-share.test.ts": 224, + "src/main/runtime/orca-runtime-automations.test.ts": 35, + "src/main/runtime/orca-runtime-browser-client-hosted.test.ts": 778, + "src/main/runtime/orca-runtime-browser-ghost-session-row-close.test.ts": 416, + "src/main/runtime/orca-runtime-browser-headless.test.ts": 450, + "src/main/runtime/orca-runtime-browser-screencast-fanout.test.ts": 316, + "src/main/runtime/orca-runtime-browser.test.ts": 604, + "src/main/runtime/orca-runtime-create-base-prefetch.test.ts": 27, + "src/main/runtime/orca-runtime-emulator-folder-workspace.test.ts": 76, + "src/main/runtime/orca-runtime-exact-worker-provider-session.test.ts": 8, + "src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts": 15, + "src/main/runtime/orca-runtime-files-preview-budget.test.ts": 104, + "src/main/runtime/orca-runtime-files-rename-authority.test.ts": 29, + "src/main/runtime/orca-runtime-files-search.test.ts": 25, + "src/main/runtime/orca-runtime-files-ssh-chunk-reads.test.ts": 68, + "src/main/runtime/orca-runtime-files-ssh-rearm.test.ts": 60, + "src/main/runtime/orca-runtime-files-terminal-artifact-grants.test.ts": 62, + "src/main/runtime/orca-runtime-files-terminal-artifact-io.test.ts": 99, + "src/main/runtime/orca-runtime-files-terminal-link-host-translation.test.ts": 30, + "src/main/runtime/orca-runtime-files-terminal-path-resolution.test.ts": 22, + "src/main/runtime/orca-runtime-files-watch-host-scope.test.ts": 15, + "src/main/runtime/orca-runtime-files-watch.test.ts": 106, + "src/main/runtime/orca-runtime-git-branch-diff.test.ts": 9, + "src/main/runtime/orca-runtime-git-diff-budget.test.ts": 25, + "src/main/runtime/orca-runtime-git.test.ts": 31, + "src/main/runtime/orca-runtime-headless-hydration-repo-gate.test.ts": 32, + "src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts": 297, + "src/main/runtime/orca-runtime-linked-issue-live-meta.integration.test.ts": 17, + "src/main/runtime/orca-runtime-mobile-agent-status-title-truthfulness.test.ts": 44, + "src/main/runtime/orca-runtime-mobile-close-preserved-resurrection.test.ts": 191, + "src/main/runtime/orca-runtime-module-size.test.ts": 20, + "src/main/runtime/orca-runtime-path-candidate-history.test.ts": 16, + "src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts": 27, + "src/main/runtime/orca-runtime-provider-reattach-launch-identity.test.ts": 21, + "src/main/runtime/orca-runtime-skill-recovery.test.ts": 20, + "src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts": 21, + "src/main/runtime/orca-runtime-structured-agent-session-launch-args.test.ts": 20, + "src/main/runtime/orca-runtime-structured-claude-account-gate.test.ts": 17, + "src/main/runtime/orca-runtime-structured-claude-gate-wiring.test.ts": 19, + "src/main/runtime/orca-runtime-structured-native-chat-settings.test.ts": 13, + "src/main/runtime/orca-runtime-structured-session-restore.test.ts": 59, + "src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts": 26, + "src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts": 302, + "src/main/runtime/orca-runtime-tab-id-collision.test.ts": 18, + "src/main/runtime/orca-runtime-tail-wait-memo.test.ts": 89, + "src/main/runtime/orca-runtime-terminal-close-continuity.test.ts": 231, + "src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts": 77, + "src/main/runtime/orca-runtime-terminal-cwd.test.ts": 62, + "src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts": 78, + "src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts": 54, + "src/main/runtime/orca-runtime-terminal-retirement.test.ts": 255, + "src/main/runtime/orca-runtime-terminal-split-authority.test.ts": 384, + "src/main/runtime/orca-runtime-test-fragment-coverage.test.ts": 5, + "src/main/runtime/orca-runtime.test.ts": 12320, + "src/main/runtime/orchestration-codex-completion-title.test.ts": 664, + "src/main/runtime/orchestration-compatibility-authority.test.ts": 42, + "src/main/runtime/orchestration-dispatch-mailbox-delivery.test.ts": 190, + "src/main/runtime/orchestration-mailbox-cold-park-idle.test.ts": 119, + "src/main/runtime/orchestration-mailbox-crash-recovery.test.ts": 155, + "src/main/runtime/orchestration-mailbox-detached-routing.test.ts": 534, + "src/main/runtime/orchestration-mailbox-filtered-waiters.test.ts": 270, + "src/main/runtime/orchestration-mailbox-notification-consistency.test.ts": 1187, + "src/main/runtime/orchestration-mailbox-pointer-cli-command.test.ts": 107, + "src/main/runtime/orchestration-mailbox-pty-write-gate.test.ts": 162, + "src/main/runtime/orchestration-mailbox-routing-races.test.ts": 642, + "src/main/runtime/orchestration-mailbox-transport-settlement.test.ts": 224, + "src/main/runtime/orchestration-message-delivery-identity.test.ts": 244, + "src/main/runtime/orchestration-messages-fake-parity.test.ts": 55, + "src/main/runtime/orchestration-structured-chat-lease.test.ts": 8461, + "src/main/runtime/orchestration-worker-workspace-resolution.test.ts": 57, + "src/main/runtime/orchestration/adopted-structured-pointer-delivery.test.ts": 13, + "src/main/runtime/orchestration/cli-command.test.ts": 7, + "src/main/runtime/orchestration/coordinator-decision-gates.test.ts": 62, + "src/main/runtime/orchestration/coordinator-dispatch-unobserved-prompt.test.ts": 59, + "src/main/runtime/orchestration/coordinator-drift-probe-coalescing.test.ts": 186, + "src/main/runtime/orchestration/coordinator-escalation-triage.test.ts": 28, + "src/main/runtime/orchestration/coordinator-stale-base-flag.test.ts": 9, + "src/main/runtime/orchestration/coordinator.test.ts": 3333, + "src/main/runtime/orchestration/db-empty-dispatch-shortcircuit.benchmark.test.ts": 64, + "src/main/runtime/orchestration/db-heartbeat-straggler-guard.test.ts": 31, + "src/main/runtime/orchestration/db-message-timestamp.test.ts": 18, + "src/main/runtime/orchestration/db-messages.test.ts": 115, + "src/main/runtime/orchestration/db-stopping-worker-task-guard.test.ts": 97, + "src/main/runtime/orchestration/db-task-create-readiness.test.ts": 174, + "src/main/runtime/orchestration/db-task-dispatch-invariant.test.ts": 521, + "src/main/runtime/orchestration/db-task-dispatch-lifecycle-guards.test.ts": 716, + "src/main/runtime/orchestration/db-task-dispatch-races.test.ts": 172, + "src/main/runtime/orchestration/db-undelivered-mailboxes.test.ts": 44, + "src/main/runtime/orchestration/db.test.ts": 836, + "src/main/runtime/orchestration/db/attempt-outcome-projection.test.ts": 285, + "src/main/runtime/orchestration/db/decision-gate-lifecycle.test.ts": 46, + "src/main/runtime/orchestration/db/dispatch-context/dispatch-capability.test.ts": 51, + "src/main/runtime/orchestration/db/dispatch-depth.test.ts": 320, + "src/main/runtime/orchestration/db/dispatch-mailbox-consumer-fencing.test.ts": 107, + "src/main/runtime/orchestration/db/dispatch-row-writer-boundary.test.ts": 587, + "src/main/runtime/orchestration/db/federated-worker-report-outcome.test.ts": 8, + "src/main/runtime/orchestration/db/federation/federated-dispatch-observation-fence.test.ts": 28, + "src/main/runtime/orchestration/db/federation/remote-dispatch-attachment-release.test.ts": 75, + "src/main/runtime/orchestration/db/hot-path-statement-compilation.test.ts": 74, + "src/main/runtime/orchestration/db/legacy-question-identity.test.ts": 7, + "src/main/runtime/orchestration/db/lifecycle-rejection-marker.test.ts": 8, + "src/main/runtime/orchestration/db/lifecycle-transition-boundary.test.ts": 6, + "src/main/runtime/orchestration/db/lifecycle-transition.test.ts": 76, + "src/main/runtime/orchestration/db/pane-key-match.test.ts": 5, + "src/main/runtime/orchestration/db/row-column-lists.test.ts": 72, + "src/main/runtime/orchestration/db/run-list-cursor.test.ts": 5, + "src/main/runtime/orchestration/db/schema/federated-home-run-migration.test.ts": 128, + "src/main/runtime/orchestration/db/schema/structured-pointer-schema-migration.test.ts": 60, + "src/main/runtime/orchestration/db/writer-run-required.test.ts": 54, + "src/main/runtime/orchestration/dispatch-consumer-generation-migration.test.ts": 143, + "src/main/runtime/orchestration/dispatch-creator-identity-migration.test.ts": 416, + "src/main/runtime/orchestration/dispatch-failure-idempotency.test.ts": 48, + "src/main/runtime/orchestration/federation-ack-checkpoints.test.ts": 6, + "src/main/runtime/orchestration/federation-acknowledgment-integrity.test.ts": 74, + "src/main/runtime/orchestration/federation-acknowledgment-migration.test.ts": 67, + "src/main/runtime/orchestration/federation-lifecycle-settlement.test.ts": 4, + "src/main/runtime/orchestration/federation-sync.test.ts": 196, + "src/main/runtime/orchestration/federation-terminal-recovery.test.ts": 31, + "src/main/runtime/orchestration/formatter.test.ts": 10, + "src/main/runtime/orchestration/groups.test.ts": 8, + "src/main/runtime/orchestration/lifecycle-caller-edges.test.ts": 76, + "src/main/runtime/orchestration/lifecycle-reconciliation.test.ts": 237, + "src/main/runtime/orchestration/lightweight-run-worker-exit-escalation.test.ts": 182, + "src/main/runtime/orchestration/mailbox-pointer-eligibility.test.ts": 67, + "src/main/runtime/orchestration/mailbox-pointer-release-query.test.ts": 120, + "src/main/runtime/orchestration/mailbox-pointer-stage.test.ts": 129, + "src/main/runtime/orchestration/mailbox-pointer-submit.test.ts": 447, + "src/main/runtime/orchestration/message-batch-atomicity.test.ts": 107, + "src/main/runtime/orchestration/mutation-receipt-capacity.test.ts": 155, + "src/main/runtime/orchestration/nested-worker-depth-migration.test.ts": 233, + "src/main/runtime/orchestration/orchestration-adopted-run-binding.test.ts": 643, + "src/main/runtime/orchestration/orchestration-all-start-versions-migration.test.ts": 1759, + "src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts": 517, + "src/main/runtime/orchestration/orchestration-db-permissions.test.ts": 37, + "src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts": 408, + "src/main/runtime/orchestration/orchestration-federated-legacy-probe.test.ts": 309, + "src/main/runtime/orchestration/orchestration-legacy-coordinator-authority-db.test.ts": 371, + "src/main/runtime/orchestration/orchestration-legacy-question-migration-db.test.ts": 445, + "src/main/runtime/orchestration/orchestration-legacy-storage-db.test.ts": 1251, + "src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts": 6, + "src/main/runtime/orchestration/orchestration-mutation-question-db.test.ts": 75, + "src/main/runtime/orchestration/orchestration-peer-capability-cache.test.ts": 11, + "src/main/runtime/orchestration/orchestration-reset-db.test.ts": 84, + "src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts": 184, + "src/main/runtime/orchestration/orchestration-run-list-compatibility.test.ts": 32, + "src/main/runtime/orchestration/orchestration-version-skew-migration.test.ts": 539, + "src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts": 352, + "src/main/runtime/orchestration/preamble.test.ts": 75, + "src/main/runtime/orchestration/r1-identity-migration.test.ts": 109, + "src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts": 148, + "src/main/runtime/orchestration/settled-question-threads-migration.test.ts": 51, + "src/main/runtime/orchestration/setup-completion-signal.test.ts": 10, + "src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts": 44, + "src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts": 14, + "src/main/runtime/orchestration/structured-pointer-operation-id.test.ts": 13, + "src/main/runtime/orchestration/structured-session-pointer-delivery.test.ts": 14, + "src/main/runtime/orchestration/structured-worker-direct-mailbox-target.test.ts": 9, + "src/main/runtime/orchestration/structured-worker-group-addressing.test.ts": 17, + "src/main/runtime/orchestration/structured-worker-journal-archive.test.ts": 32, + "src/main/runtime/orchestration/task-deps-flag.test.ts": 15, + "src/main/runtime/orchestration/worker-attention-context.test.ts": 10, + "src/main/runtime/orchestration/worker-output-archive-bounding.test.ts": 58, + "src/main/runtime/orchestration/worker-output-archive.test.ts": 18, + "src/main/runtime/orchestration/worker-output-cursor.test.ts": 8, + "src/main/runtime/orchestration/worker-provider-session.test.ts": 7, + "src/main/runtime/orchestration/worker-start-unobserved-prompt-settlement.test.ts": 63, + "src/main/runtime/orchestration/worker-transcript-payload.test.ts": 15, + "src/main/runtime/orchestration/worker-transcript-read.test.ts": 58, + "src/main/runtime/orchestration/worker-transcript-remote-read.test.ts": 60, + "src/main/runtime/pairing-endpoint.test.ts": 24, + "src/main/runtime/pty-exit-agent-status-reconciliation.test.ts": 35, + "src/main/runtime/pty-exit-per-pty-map-reaper-ratchet.test.ts": 13, + "src/main/runtime/pty-inventory-liveness-verdict.test.ts": 58, + "src/main/runtime/pty-inventory-partial-relay-liveness.test.ts": 31, + "src/main/runtime/pty-shell-ownership-mirror.test.ts": 1507, + "src/main/runtime/pty-transcript-prune-wait-cache.test.ts": 24, + "src/main/runtime/pty-waiver-source-invariant.test.ts": 9, + "src/main/runtime/public-ssh-state.test.ts": 7, + "src/main/runtime/push/desktop-push-service-unreadable-outbox.test.ts": 32, + "src/main/runtime/push/desktop-push-service.test.ts": 79, + "src/main/runtime/push/push-agent-state.test.ts": 9, + "src/main/runtime/push/push-cleanup-auth-expiry.test.ts": 50, + "src/main/runtime/push/push-delivery-policy.test.ts": 9, + "src/main/runtime/push/push-device-registration-persistence.test.ts": 20, + "src/main/runtime/push/push-dispatcher.test.ts": 13, + "src/main/runtime/push/push-gateway-client.test.ts": 149, + "src/main/runtime/push/push-gateway-session.test.ts": 136, + "src/main/runtime/push/push-host-proof-vector.test.ts": 19, + "src/main/runtime/push/push-host-proof.test.ts": 100, + "src/main/runtime/push/push-outcome-counters.test.ts": 6, + "src/main/runtime/push/push-policy-pipeline.integration.test.ts": 38, + "src/main/runtime/push/push-preferences.test.ts": 10, + "src/main/runtime/push/push-registration-races.test.ts": 65, + "src/main/runtime/push/push-registration-rpc.test.ts": 45, + "src/main/runtime/push/push-unpair-persistence.test.ts": 37, + "src/main/runtime/push/push-unregister-outbox.test.ts": 12, + "src/main/runtime/quarter-circle-title-send-authorization.test.ts": 16463, + "src/main/runtime/recent-pty-output-buffer.test.ts": 63, + "src/main/runtime/relay/desktop-relay-service-broker-liveness.test.ts": 10, + "src/main/runtime/relay/desktop-relay-service.test.ts": 12, + "src/main/runtime/relay/mobile-relay-e2ee.integration.test.ts": 83, + "src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts": 18, + "src/main/runtime/relay/relay-auth-coordinator.test.ts": 1169, + "src/main/runtime/relay/relay-auth-host-close-reason.test.ts": 67, + "src/main/runtime/relay/relay-control-client.test.ts": 287, + "src/main/runtime/relay/relay-control-close-reason.test.ts": 40, + "src/main/runtime/relay/relay-control-origin.test.ts": 22, + "src/main/runtime/relay/relay-control-request-retirement.test.ts": 23, + "src/main/runtime/relay/relay-demand-ledger.test.ts": 12, + "src/main/runtime/relay/relay-host-proof.test.ts": 40, + "src/main/runtime/relay/relay-http-client.test.ts": 86, + "src/main/runtime/relay/relay-region-correction.test.ts": 89, + "src/main/runtime/relay/relay-region-preference.test.ts": 90, + "src/main/runtime/relay/relay-region-probe-log.test.ts": 79, + "src/main/runtime/relay/relay-region-refresh.test.ts": 17, + "src/main/runtime/relay/relay-renewal-jitter.test.ts": 506, + "src/main/runtime/relay/relay-revoke-outbox.test.ts": 13, + "src/main/runtime/relay/relay-session-broker.test.ts": 405, + "src/main/runtime/remote-agent-session-host-authority.integration.test.ts": 138, + "src/main/runtime/remote-browser-screencast-frame-admission.test.ts": 47, + "src/main/runtime/remote-desktop-driver.test.ts": 99, + "src/main/runtime/remote-runtime-close-intent.integration.test.ts": 90, + "src/main/runtime/remote-runtime-request-connection.integration.test.ts": 260, + "src/main/runtime/remote-server-updater.test.ts": 9, + "src/main/runtime/renderer-browser-session-reconciliation.test.ts": 11, + "src/main/runtime/repo-icon-fork-backfill.test.ts": 16, + "src/main/runtime/repo-worktree-admin-fingerprint.test.ts": 835, + "src/main/runtime/repo-worktree-resolution-scan.test.ts": 4, + "src/main/runtime/repo-worktree-row-resolution.test.ts": 16, + "src/main/runtime/retained-tail-redraw-window.equivalence.test.ts": 329, + "src/main/runtime/rpc/core-typed-method-contract.test.ts": 9, + "src/main/runtime/rpc/dispatcher-browser-client-automation.test.ts": 23, + "src/main/runtime/rpc/dispatcher-computer-errors.test.ts": 20, + "src/main/runtime/rpc/dispatcher-feature-interactions.test.ts": 17, + "src/main/runtime/rpc/dispatcher-request-parsing.test.ts": 18, + "src/main/runtime/rpc/e2ee-channel-text-backpressure.test.ts": 55, + "src/main/runtime/rpc/e2ee-channel-v2.test.ts": 81, + "src/main/runtime/rpc/e2ee-channel.test.ts": 196, + "src/main/runtime/rpc/e2ee-crypto.test.ts": 41, + "src/main/runtime/rpc/e2ee-integration.test.ts": 57, + "src/main/runtime/rpc/errors.test.ts": 22, + "src/main/runtime/rpc/methods/accounts.test.ts": 21, + "src/main/runtime/rpc/methods/agent-hooks.test.ts": 12, + "src/main/runtime/rpc/methods/agent-session.test.ts": 38, + "src/main/runtime/rpc/methods/agent-skill-sharing-capability-grant.test.ts": 25, + "src/main/runtime/rpc/methods/ai-vault.test.ts": 156, + "src/main/runtime/rpc/methods/artifact-sharing-capability-grant.test.ts": 28, + "src/main/runtime/rpc/methods/artifacts.test.ts": 280, + "src/main/runtime/rpc/methods/automation-scoped-list-methods.test.ts": 31, + "src/main/runtime/rpc/methods/automations.test.ts": 45, + "src/main/runtime/rpc/methods/browser-client-host-attach-adoption.test.ts": 236, + "src/main/runtime/rpc/methods/browser-client-host-reconciliation.test.ts": 26, + "src/main/runtime/rpc/methods/browser-client-host.test.ts": 30, + "src/main/runtime/rpc/methods/browser-client-page-metadata.test.ts": 20, + "src/main/runtime/rpc/methods/browser-network-tunnel.test.ts": 233, + "src/main/runtime/rpc/methods/browser-tab-create-schema.test.ts": 12, + "src/main/runtime/rpc/methods/browser.test.ts": 58, + "src/main/runtime/rpc/methods/client-events.test.ts": 7, + "src/main/runtime/rpc/methods/client-native-chat-settings.test.ts": 22, + "src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts": 41, + "src/main/runtime/rpc/methods/client-ui-task-resume-state.test.ts": 26, + "src/main/runtime/rpc/methods/client-ui.test.ts": 96, + "src/main/runtime/rpc/methods/clipboard.test.ts": 44, + "src/main/runtime/rpc/methods/computer-actions.test.ts": 15, + "src/main/runtime/rpc/methods/computer.test.ts": 33, + "src/main/runtime/rpc/methods/diagnostics.test.ts": 8, + "src/main/runtime/rpc/methods/file-watch-event-batcher.test.ts": 26, + "src/main/runtime/rpc/methods/files-doc-preview.test.ts": 13, + "src/main/runtime/rpc/methods/files-list-all-page-size.test.ts": 17, + "src/main/runtime/rpc/methods/files-path-search.test.ts": 150, + "src/main/runtime/rpc/methods/files-preview-transport-budget.test.ts": 17, + "src/main/runtime/rpc/methods/files-terminal-path-resolution.test.ts": 20, + "src/main/runtime/rpc/methods/files-watch-cancellation.test.ts": 68, + "src/main/runtime/rpc/methods/files-watch-cleanup.test.ts": 64, + "src/main/runtime/rpc/methods/files.test.ts": 196, + "src/main/runtime/rpc/methods/git-diff-transport-budget.test.ts": 50, + "src/main/runtime/rpc/methods/git.test.ts": 72, + "src/main/runtime/rpc/methods/github-pr-refresh-reason.test.ts": 14, + "src/main/runtime/rpc/methods/github.test.ts": 65, + "src/main/runtime/rpc/methods/gitlab.test.ts": 58, + "src/main/runtime/rpc/methods/host-capabilities.test.ts": 10, + "src/main/runtime/rpc/methods/hosted-review.test.ts": 25, + "src/main/runtime/rpc/methods/jira.test.ts": 34, + "src/main/runtime/rpc/methods/linear-agent-access.test.ts": 77, + "src/main/runtime/rpc/methods/linear-agent-project-access.test.ts": 708, + "src/main/runtime/rpc/methods/linear.test.ts": 47, + "src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.test.ts": 6, + "src/main/runtime/rpc/methods/native-chat.test.ts": 29, + "src/main/runtime/rpc/methods/notification-preferences.test.ts": 6, + "src/main/runtime/rpc/methods/notification-reconnect-cooldown.test.ts": 10, + "src/main/runtime/rpc/methods/orchestration-dispatch-error-codes.test.ts": 105, + "src/main/runtime/rpc/methods/orchestration-structured-worker-abandon.test.ts": 69, + "src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts": 14, + "src/main/runtime/rpc/methods/orchestration-structured-worker-redrive.test.ts": 88, + "src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts": 16, + "src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts": 13, + "src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts": 89, + "src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts": 188, + "src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts": 9, + "src/main/runtime/rpc/methods/orchestration-worker-support-unknown.test.ts": 8, + "src/main/runtime/rpc/methods/orchestration/cli-runtime-boundary.test.ts": 96, + "src/main/runtime/rpc/methods/orchestration/federation/federated-attach-receipt.test.ts": 5, + "src/main/runtime/rpc/methods/orchestration/federation/federated-fleet-snapshot.test.ts": 30, + "src/main/runtime/rpc/methods/orchestration/federation/federated-message-targeting.test.ts": 68, + "src/main/runtime/rpc/methods/orchestration/federation/federated-release-safety.test.ts": 105, + "src/main/runtime/rpc/methods/orchestration/federation/federated-transport-safety.test.ts": 31, + "src/main/runtime/rpc/methods/orchestration/federation/federated-worker-start-receipt.test.ts": 50, + "src/main/runtime/rpc/methods/orchestration/federation/federation-agent-launch.test.ts": 60, + "src/main/runtime/rpc/methods/orchestration/federation/federation-control-mail.test.ts": 281, + "src/main/runtime/rpc/methods/orchestration/federation/federation-effects.test.ts": 4, + "src/main/runtime/rpc/methods/orchestration/federation/federation-folder-placement.test.ts": 35, + "src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts": 1435, + "src/main/runtime/rpc/methods/orchestration/federation/federation-liveness-verdict.test.ts": 279, + "src/main/runtime/rpc/methods/orchestration/federation/federation-output.test.ts": 755, + "src/main/runtime/rpc/methods/orchestration/federation/federation-setup.test.ts": 129, + "src/main/runtime/rpc/methods/orchestration/federation/federation-start-prompt-budget.test.ts": 31, + "src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.test.ts": 11, + "src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts": 1549, + "src/main/runtime/rpc/methods/orchestration/gates/gate-run-authorization.test.ts": 676, + "src/main/runtime/rpc/methods/orchestration/gates/gates.test.ts": 164, + "src/main/runtime/rpc/methods/orchestration/messaging/ask.test.ts": 430, + "src/main/runtime/rpc/methods/orchestration/messaging/check-superseded-terminal.test.ts": 136, + "src/main/runtime/rpc/methods/orchestration/messaging/check-worker-consumer-fencing.test.ts": 242, + "src/main/runtime/rpc/methods/orchestration/messaging/check-worker-federated-attachment.test.ts": 453, + "src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts": 473, + "src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.test.ts": 238, + "src/main/runtime/rpc/methods/orchestration/messaging/send-dispatch-authority.test.ts": 138, + "src/main/runtime/rpc/methods/orchestration/messaging/send-group.test.ts": 387, + "src/main/runtime/rpc/methods/orchestration/messaging/send-invalid-type.test.ts": 55, + "src/main/runtime/rpc/methods/orchestration/messaging/send-receipt-plumbing.test.ts": 64, + "src/main/runtime/rpc/methods/orchestration/messaging/send-unbound-terminals.test.ts": 49, + "src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts": 570, + "src/main/runtime/rpc/methods/orchestration/messaging/settled-dispatch-mail.test.ts": 100, + "src/main/runtime/rpc/methods/orchestration/runs/migration-behavior.test.ts": 156, + "src/main/runtime/rpc/methods/orchestration/runs/run-receipt.test.ts": 7, + "src/main/runtime/rpc/methods/orchestration/runs/runs.test.ts": 329, + "src/main/runtime/rpc/methods/orchestration/runs/tasks-dispatch.test.ts": 394, + "src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts": 1032, + "src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts": 364, + "src/main/runtime/rpc/methods/orchestration/worker/context-only-dispatch-retry.test.ts": 84, + "src/main/runtime/rpc/methods/orchestration/worker/fleet-status-observed-identity.test.ts": 15, + "src/main/runtime/rpc/methods/orchestration/worker/fleet-status-terminal-identity.test.ts": 9, + "src/main/runtime/rpc/methods/orchestration/worker/legacy-dispatch-projection.test.ts": 154, + "src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-observation.test.ts": 133, + "src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-release.test.ts": 105, + "src/main/runtime/rpc/methods/orchestration/worker/self-dispatch-nesting-depth.test.ts": 55, + "src/main/runtime/rpc/methods/orchestration/worker/structured-worker-launch-seed-options.test.ts": 10, + "src/main/runtime/rpc/methods/orchestration/worker/worker-interactive-wait.test.ts": 111, + "src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts": 32, + "src/main/runtime/rpc/methods/orchestration/worker/worker-list-pagination.test.ts": 215, + "src/main/runtime/rpc/methods/orchestration/worker/worker-list-run-scope-rpc.test.ts": 81, + "src/main/runtime/rpc/methods/orchestration/worker/worker-observation.test.ts": 16, + "src/main/runtime/rpc/methods/orchestration/worker/worker-output.test.ts": 36, + "src/main/runtime/rpc/methods/orchestration/worker/worker-read-projection.test.ts": 60, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release-archive.test.ts": 213, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release-inventory.test.ts": 285, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release-liveness-verdict.test.ts": 17, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts": 283, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release-ownership-guard.test.ts": 151, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts": 390, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release.test.ts": 651, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-budgets.test.ts": 5, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-outcome-classification.test.ts": 8, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-budget.test.ts": 70, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts": 355, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-terminal-target.test.ts": 106, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.test.ts": 10, + "src/main/runtime/rpc/methods/orchestration/worker/worker-stop-capability.test.ts": 14, + "src/main/runtime/rpc/methods/orchestration/worker/worker-stop-exit-race.test.ts": 109, + "src/main/runtime/rpc/methods/orchestration/worker/worker-stop-liveness-verdict.test.ts": 136, + "src/main/runtime/rpc/methods/orchestration/worker/worker-terminal-custody-at-creation.test.ts": 505, + "src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts": 735, + "src/main/runtime/rpc/methods/orchestration/worker/workers-recovery.test.ts": 238, + "src/main/runtime/rpc/methods/paired-caller-host-id.test.ts": 8, + "src/main/runtime/rpc/methods/pairing.test.ts": 29, + "src/main/runtime/rpc/methods/plugins.test.ts": 13, + "src/main/runtime/rpc/methods/preflight.test.ts": 14, + "src/main/runtime/rpc/methods/project-host-setup-self-host-stamp.test.ts": 16, + "src/main/runtime/rpc/methods/repo-badge-color.test.ts": 14, + "src/main/runtime/rpc/methods/repo.test.ts": 48, + "src/main/runtime/rpc/methods/runtime-client-capabilities.test.ts": 14, + "src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts": 25, + "src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts": 14, + "src/main/runtime/rpc/methods/session-tab-browser-placement-mutations.test.ts": 18, + "src/main/runtime/rpc/methods/session-tab-browser-placement-projection.test.ts": 14, + "src/main/runtime/rpc/methods/session-tabs-inventory-census-race.test.ts": 239, + "src/main/runtime/rpc/methods/session-tabs-inventory-rpc.test.ts": 41, + "src/main/runtime/rpc/methods/session-tabs-move-validation.test.ts": 41, + "src/main/runtime/rpc/methods/session-tabs-retirement-proof-delta.test.ts": 20, + "src/main/runtime/rpc/methods/session-tabs-schemas.test.ts": 18, + "src/main/runtime/rpc/methods/session-tabs-structured-restore.test.ts": 13, + "src/main/runtime/rpc/methods/session-tabs-unsubscribe.test.ts": 16, + "src/main/runtime/rpc/methods/session-tabs.test.ts": 37, + "src/main/runtime/rpc/methods/skills.test.ts": 26, + "src/main/runtime/rpc/methods/speech.test.ts": 20, + "src/main/runtime/rpc/methods/ssh.test.ts": 17, + "src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts": 47, + "src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts": 87, + "src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts": 24, + "src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts": 954, + "src/main/runtime/rpc/methods/structured-agent-session-policy.test.ts": 6, + "src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts": 23, + "src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts": 20, + "src/main/runtime/rpc/methods/structured-agent-session.test.ts": 127, + "src/main/runtime/rpc/methods/structured-worker-read-cursor.test.ts": 10, + "src/main/runtime/rpc/methods/structured-worker-stop-receipt.test.ts": 56, + "src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts": 37, + "src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts": 14, + "src/main/runtime/rpc/methods/terminal-legacy-stream-id-characterization.test.ts": 165, + "src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts": 40, + "src/main/runtime/rpc/methods/terminal-navigation.test.ts": 15, + "src/main/runtime/rpc/methods/terminal-read-screen-cursor.test.ts": 10, + "src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts": 20, + "src/main/runtime/rpc/methods/terminal/terminal-inspect-process-params.test.ts": 7, + "src/main/runtime/rpc/methods/updater.test.ts": 7, + "src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.test.ts": 17, + "src/main/runtime/rpc/methods/workspace-ports.test.ts": 19, + "src/main/runtime/rpc/methods/worktree-catalog-snapshot-method.test.ts": 17, + "src/main/runtime/rpc/methods/worktree-create-args.test.ts": 14, + "src/main/runtime/rpc/methods/worktree-create-navigation.test.ts": 26, + "src/main/runtime/rpc/methods/worktree-github-pr-suppression.test.ts": 20, + "src/main/runtime/rpc/methods/worktree-missing-terminal-teardown.test.ts": 13, + "src/main/runtime/rpc/methods/worktree-retired-names.test.ts": 12, + "src/main/runtime/rpc/methods/worktree-rm-host-qualification.test.ts": 26, + "src/main/runtime/rpc/methods/worktree-rm-pty-waiver.test.ts": 10, + "src/main/runtime/rpc/methods/worktree-schemas.test.ts": 16, + "src/main/runtime/rpc/methods/worktree.test.ts": 70, + "src/main/runtime/rpc/mobile-auth-acl-critical-path.test.ts": 419, + "src/main/runtime/rpc/mobile-clipboard-image-provenance.test.ts": 25, + "src/main/runtime/rpc/mobile-e2ee-outbound-memory-budget.test.ts": 7, + "src/main/runtime/rpc/mobile-e2ee-v2-desktop-session.test.ts": 33, + "src/main/runtime/rpc/mobile-e2ee-v2-key-schedule.test.ts": 46, + "src/main/runtime/rpc/mobile-socket-wiring.test.ts": 117, + "src/main/runtime/rpc/orchestration-11745-regression-verification.test.ts": 1475, + "src/main/runtime/rpc/orchestration-commit-notify-characterization.test.ts": 326, + "src/main/runtime/rpc/orchestration-contract-fence.test.ts": 121, + "src/main/runtime/rpc/orchestration-current-authority-precedence.test.ts": 657, + "src/main/runtime/rpc/orchestration-legacy-compatibility-dispatcher.test.ts": 3011, + "src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts": 963, + "src/main/runtime/rpc/orchestration-legacy-fence-jurisdiction.test.ts": 3247, + "src/main/runtime/rpc/orchestration-legacy-question-takeover.test.ts": 293, + "src/main/runtime/rpc/orchestration-legacy-run-routing.test.ts": 363, + "src/main/runtime/rpc/orchestration-legacy-takeover-current-authority.test.ts": 152, + "src/main/runtime/rpc/orchestration-legacy-takeover-delivery.test.ts": 199, + "src/main/runtime/rpc/orchestration-legacy-takeover-dispatcher.test.ts": 498, + "src/main/runtime/rpc/orchestration-mutation-executor.test.ts": 186, + "src/main/runtime/rpc/orchestration-mutation-ledger.test.ts": 304, + "src/main/runtime/rpc/orchestration-mutation-request-show-legacy.test.ts": 94, + "src/main/runtime/rpc/orchestration-mutation-request-show.test.ts": 158, + "src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts": 293, + "src/main/runtime/rpc/orchestration-task-dispatch-invariant.test.ts": 332, + "src/main/runtime/rpc/relay-transport.test.ts": 146, + "src/main/runtime/rpc/remote-runtime-server-heartbeat-missed-probe-tolerance.test.ts": 23, + "src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts": 9, + "src/main/runtime/rpc/runtime-client-capabilities.test.ts": 6, + "src/main/runtime/rpc/runtime-close-attribution-topology.test.ts": 33, + "src/main/runtime/rpc/runtime-close-attribution.test.ts": 19, + "src/main/runtime/rpc/schemas.test.ts": 38, + "src/main/runtime/rpc/streaming.test.ts": 16, + "src/main/runtime/rpc/terminal-agent-prompt-send.test.ts": 20, + "src/main/runtime/rpc/terminal-agent-send-guard.test.ts": 39, + "src/main/runtime/rpc/terminal-geometry-stale-handle.test.ts": 22, + "src/main/runtime/rpc/terminal-lease-stale-handle-survival.test.ts": 168, + "src/main/runtime/rpc/terminal-list-host-scope-transport.test.ts": 11, + "src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts": 563, + "src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts": 1720, + "src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts": 695, + "src/main/runtime/rpc/terminal-multiplex-end-verdict.test.ts": 328, + "src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts": 27, + "src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts": 22, + "src/main/runtime/rpc/terminal-multiplex-initial-snapshot-buffering.test.ts": 294, + "src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts": 809, + "src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts": 455, + "src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts": 446, + "src/main/runtime/rpc/terminal-multiplex-resync-replay-trim.test.ts": 16, + "src/main/runtime/rpc/terminal-multiplex-round-robin.test.ts": 6, + "src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts": 644, + "src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts": 799, + "src/main/runtime/rpc/terminal-multiplex-subscribe-slot-recovery.test.ts": 177, + "src/main/runtime/rpc/terminal-opencode-send-guard.integration.test.ts": 42, + "src/main/runtime/rpc/terminal-output-batching.test.ts": 366, + "src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts": 14856, + "src/main/runtime/rpc/terminal-output-frame-source-ranges.test.ts": 11, + "src/main/runtime/rpc/terminal-prompt-delivery-receipt.test.ts": 318, + "src/main/runtime/rpc/terminal-provider-snapshot-sequence.test.ts": 220, + "src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts": 531, + "src/main/runtime/rpc/terminal-send-agent-session-lease.test.ts": 27, + "src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts": 18, + "src/main/runtime/rpc/terminal-send.test.ts": 284, + "src/main/runtime/rpc/terminal-source-range-ledger.test.ts": 11, + "src/main/runtime/rpc/terminal-stream-byte-length.test.ts": 5718, + "src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts": 66, + "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts": 605, + "src/main/runtime/rpc/terminal-subscribe-lease-only.test.ts": 66, + "src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts": 226, + "src/main/runtime/rpc/terminal-subscribe-ownership.test.ts": 226, + "src/main/runtime/rpc/terminal-subscribe-reconnect-rebind.test.ts": 139, + "src/main/runtime/rpc/terminal-subscribe-relay-drop-lease-survival.test.ts": 178, + "src/main/runtime/rpc/terminal-subscribe-renderer-recovery-output.test.ts": 66, + "src/main/runtime/rpc/unix-socket-transport.test.ts": 8, + "src/main/runtime/rpc/unpaired-device-auth-throttle.test.ts": 9, + "src/main/runtime/rpc/worktree-catalog-snapshot.test.ts": 8, + "src/main/runtime/rpc/ws-fallback-port-store.test.ts": 4, + "src/main/runtime/rpc/ws-transport-accept-order.test.ts": 17, + "src/main/runtime/rpc/ws-transport-static-web.test.ts": 6055, + "src/main/runtime/rpc/ws-transport-transient-packet-loss.test.ts": 635, + "src/main/runtime/rpc/ws-transport.test.ts": 976, + "src/main/runtime/runtime-binary-message-router.test.ts": 4, + "src/main/runtime/runtime-browser-client-automation.test.ts": 19, + "src/main/runtime/runtime-browser-client-page-adoption.test.ts": 32, + "src/main/runtime/runtime-browser-client-page-creation.test.ts": 20, + "src/main/runtime/runtime-browser-client-page-recovery.test.ts": 28, + "src/main/runtime/runtime-browser-client-page-restored-recovery.test.ts": 14, + "src/main/runtime/runtime-browser-network-execution-host.test.ts": 7, + "src/main/runtime/runtime-browser-page-registry.test.ts": 13, + "src/main/runtime/runtime-client-settings-minimax-projection.test.ts": 7, + "src/main/runtime/runtime-extraction-regressions.test.ts": 20, + "src/main/runtime/runtime-file-target-connection-field-ratchet.test.ts": 11, + "src/main/runtime/runtime-file-target-execution-host.test.ts": 33, + "src/main/runtime/runtime-folder-workspace.test.ts": 10, + "src/main/runtime/runtime-git-api-contract.test.ts": 7, + "src/main/runtime/runtime-git-branch-compare-admission.test.ts": 7, + "src/main/runtime/runtime-git-command-target.test.ts": 8, + "src/main/runtime/runtime-git-conflict-operation-routing.test.ts": 11, + "src/main/runtime/runtime-git-execution-host-ownership.test.ts": 10, + "src/main/runtime/runtime-git-generation-admission.test.ts": 11, + "src/main/runtime/runtime-git-status-admission.test.ts": 14, + "src/main/runtime/runtime-git-sync-commands.test.ts": 10, + "src/main/runtime/runtime-git-target-execution-host.test.ts": 33, + "src/main/runtime/runtime-graph-reload-lifecycle.test.ts": 10, + "src/main/runtime/runtime-hook-agent-row-selection.test.ts": 11, + "src/main/runtime/runtime-linear-read-commands.test.ts": 9, + "src/main/runtime/runtime-local-worktree-materialization.test.ts": 7, + "src/main/runtime/runtime-local-worktree-terminal-startup.test.ts": 6, + "src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts": 29, + "src/main/runtime/runtime-managed-worktree-metadata.test.ts": 7, + "src/main/runtime/runtime-managed-worktree-queries.test.ts": 22, + "src/main/runtime/runtime-metadata-ownership-watch.test.ts": 32, + "src/main/runtime/runtime-metadata.test.ts": 38, + "src/main/runtime/runtime-mobile-agent-status-builder.test.ts": 5, + "src/main/runtime/runtime-mobile-file-path-search.test.ts": 15, + "src/main/runtime/runtime-owned-terminal-publication-lineage.test.ts": 28, + "src/main/runtime/runtime-project-group-controller-folder-delete.test.ts": 7, + "src/main/runtime/runtime-project-host-setup-controller.test.ts": 16, + "src/main/runtime/runtime-remote-fetch-ref-maintenance.test.ts": 63, + "src/main/runtime/runtime-remove-project-host-scope.test.ts": 23, + "src/main/runtime/runtime-resolved-worktree-cache.test.ts": 6, + "src/main/runtime/runtime-rpc-browser-host-admission.test.ts": 31, + "src/main/runtime/runtime-rpc-device-revocation.test.ts": 99, + "src/main/runtime/runtime-rpc-long-poll-transport.test.ts": 1931, + "src/main/runtime/runtime-rpc-metadata-lifecycle.test.ts": 29, + "src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts": 53, + "src/main/runtime/runtime-rpc-mobile-native-chat-settings.test.ts": 5, + "src/main/runtime/runtime-rpc-mobile-terminal-streaming.test.ts": 575, + "src/main/runtime/runtime-rpc-orchestration-db-migration.test.ts": 56, + "src/main/runtime/runtime-rpc-pairing-mode-persistence.test.ts": 139, + "src/main/runtime/runtime-rpc-pairing-offer.test.ts": 71, + "src/main/runtime/runtime-rpc-relay-pairing.test.ts": 431, + "src/main/runtime/runtime-rpc-request-authorization.test.ts": 83, + "src/main/runtime/runtime-rpc-startup-failure.test.ts": 19, + "src/main/runtime/runtime-rpc-terminal-list.test.ts": 641, + "src/main/runtime/runtime-rpc-websocket-bind-host.test.ts": 104, + "src/main/runtime/runtime-rpc-websocket-long-poll-caps.test.ts": 67, + "src/main/runtime/runtime-rpc-worktree-queries.test.ts": 73, + "src/main/runtime/runtime-search-line-fragments.test.ts": 9, + "src/main/runtime/runtime-skill-install-authority.test.ts": 4, + "src/main/runtime/runtime-skill-install-commands.test.ts": 8, + "src/main/runtime/runtime-skill-install-queries.test.ts": 15, + "src/main/runtime/runtime-socket-sweep.test.ts": 8, + "src/main/runtime/runtime-terminal-idle-polls.test.ts": 14, + "src/main/runtime/runtime-terminal-orphan-topology-validation.test.ts": 17, + "src/main/runtime/runtime-terminal-spawn-push-target-materialization.test.ts": 9, + "src/main/runtime/runtime-worktree-agent-rows-structured.test.ts": 13, + "src/main/runtime/runtime-worktree-agent-sources.test.ts": 8, + "src/main/runtime/runtime-worktree-agent-startup.test.ts": 11, + "src/main/runtime/runtime-worktree-ps-summaries.test.ts": 7, + "src/main/runtime/runtime-worktree-selection.test.ts": 8, + "src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts": 87, + "src/main/runtime/saved-structured-agent-session-restoration.test.ts": 5, + "src/main/runtime/selected-review-branch.test.ts": 11, + "src/main/runtime/session-tabs-inventory-publication.test.ts": 47, + "src/main/runtime/settled-worker-process-replacement.test.ts": 96, + "src/main/runtime/structured-agent-session-close.test.ts": 10, + "src/main/runtime/structured-agent-session-integration-replay.test.ts": 400, + "src/main/runtime/structured-agent-session-integration.test.ts": 816, + "src/main/runtime/structured-agent-session-pty-binding.test.ts": 16, + "src/main/runtime/structured-agent-session-rollback-compatibility.test.ts": 39, + "src/main/runtime/structured-agent-session-runtime-exit.test.ts": 348, + "src/main/runtime/structured-agent-session-runtime.test.ts": 33, + "src/main/runtime/structured-agent-session-support-probe.test.ts": 25, + "src/main/runtime/structured-claude-auth-policy-wiring.test.ts": 8, + "src/main/runtime/structured-conversation-tab-replacement.test.ts": 6, + "src/main/runtime/structured-session-worktree-teardown.test.ts": 2436, + "src/main/runtime/structured-tui-exit-proof.test.ts": 14, + "src/main/runtime/structured-tui-idle-evidence.test.ts": 5, + "src/main/runtime/structured-tui-process-identity.test.ts": 26, + "src/main/runtime/structured-tui-recovery-claim-match.test.ts": 13, + "src/main/runtime/structured-worker-agent-presence.test.ts": 6, + "src/main/runtime/structured-worker-authority.test.ts": 7, + "src/main/runtime/structured-worker-child-identity-env.test.ts": 6, + "src/main/runtime/structured-worker-hook-attestation.test.ts": 7, + "src/main/runtime/structured-worker-identity.test.ts": 17, + "src/main/runtime/structured-worker-mail-routing.test.ts": 9, + "src/main/runtime/structured-worker-takeover-pane-key.test.ts": 6, + "src/main/runtime/structured-worker-terminal-read.test.ts": 14, + "src/main/runtime/structured-worker-terminal-refusal.test.ts": 8, + "src/main/runtime/terminal-ansi-pending-retention.test.ts": 464, + "src/main/runtime/terminal-focus-navigation-coalescer.test.ts": 21, + "src/main/runtime/terminal-identity-probe.test.ts": 8, + "src/main/runtime/terminal-interactive-wait-visibility.test.ts": 6198, + "src/main/runtime/terminal-leaf-tab-resolution.test.ts": 6, + "src/main/runtime/terminal-list-execution-host-scope.test.ts": 3128, + "src/main/runtime/terminal-list-payload-size.test.ts": 172, + "src/main/runtime/terminal-list-stale-leaf-liveness.test.ts": 42, + "src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts": 26, + "src/main/runtime/terminal-model-query-authority.test.ts": 9, + "src/main/runtime/terminal-orphan-owner.test.ts": 7, + "src/main/runtime/terminal-orphan-topology.test.ts": 12, + "src/main/runtime/terminal-pane-recovery-liveness-gate.test.ts": 46, + "src/main/runtime/terminal-projection.test.ts": 6, + "src/main/runtime/terminal-pty-exit-waiter.test.ts": 28, + "src/main/runtime/terminal-query-responder.test.ts": 178, + "src/main/runtime/terminal-restore-record-seed.test.ts": 69, + "src/main/runtime/terminal-retirement-proof-emitted-frame.test.ts": 23, + "src/main/runtime/terminal-retirement-proof-publication.test.ts": 77, + "src/main/runtime/terminal-send-stale-leaf-liveness.test.ts": 180, + "src/main/runtime/terminal-subscribe-exit-waiter-leak.test.ts": 24, + "src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts": 870, + "src/main/runtime/terminal-tail-buffer.test.ts": 5851, + "src/main/runtime/terminal-tail-row-retention.test.ts": 167, + "src/main/runtime/terminal-tail-sentinel-index.test.ts": 4042, + "src/main/runtime/terminal-tail-whitespace.test.ts": 11, + "src/main/runtime/terminal-vertical-control-scan.test.ts": 8, + "src/main/runtime/terminal-wait-detection.test.ts": 20, + "src/main/runtime/terminal-wait-results.test.ts": 6, + "src/main/runtime/wait-blocked-check-state.test.ts": 112, + "src/main/runtime/wait-blocked-keyword-carry-retention.test.ts": 12, + "src/main/runtime/windows-default-route-interfaces.test.ts": 9, + "src/main/runtime/windows-drive-listing.test.ts": 12, + "src/main/runtime/windows-firewall-remote-scope.test.ts": 19, + "src/main/runtime/windows-mobile-firewall.test.ts": 14, + "src/main/runtime/workspace-session-membership-scaling.test.ts": 13, + "src/main/runtime/worktree-launch-host-repo.test.ts": 10, + "src/main/runtime/worktree-list-host-scope.test.ts": 32, + "src/main/runtime/worktree-path-selector-wsl-posix.test.ts": 46, + "src/main/runtime/worktree-ps-degraded-repo-scan.test.ts": 94, + "src/main/runtime/worktree-ps-host-scope.test.ts": 40, + "src/main/runtime/worktree-rm-id-selector-path-spelling.test.ts": 47, + "src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts": 531, + "src/main/runtime/worktree-scan-execution-host-routing.test.ts": 33, + "src/main/runtime/worktree-teardown-unstopped-pty.test.ts": 204, + "src/main/runtime/worktree-teardown.test.ts": 235, + "src/main/runtime/worktree-terminal-mutation-lock.test.ts": 13, + "src/main/serve-update-handoff.app-environment.test.ts": 907, + "src/main/serve-update-handoff.test.ts": 8, + "src/main/server/serve-readiness.test.ts": 10, + "src/main/server/serve-stdout-boundary.test.ts": 8, + "src/main/shell-prompt-readiness-probe.test.ts": 28, + "src/main/shell-startup-identity-scanner.test.ts": 6, + "src/main/shell-startup-output-scanner.test.ts": 24, + "src/main/shell-wrapper-content-address.test.ts": 9, + "src/main/shell-wrapper-generated-file-snapshot.test.ts": 40, + "src/main/skills/agent-skill-selection.test.ts": 22, + "src/main/skills/claude-plugin-skill-sources-wsl.test.ts": 6, + "src/main/skills/claude-plugin-skill-sources.test.ts": 7, + "src/main/skills/discovery.test.ts": 108, + "src/main/skills/skill-bundle-artifacts.test.ts": 39, + "src/main/skills/skill-bundle-creation.test.ts": 628, + "src/main/skills/skill-bundle-install-service.test.ts": 196, + "src/main/skills/skill-bundle-observability-summary.test.ts": 4, + "src/main/skills/skill-bundle-ssh-relay-service.test.ts": 105, + "src/main/skills/skill-candidate-concurrency.test.ts": 29, + "src/main/skills/skill-client-mediated-transfer-cancellation.test.ts": 20, + "src/main/skills/skill-client-mediated-transfer.test.ts": 29, + "src/main/skills/skill-cloud-direct-upload.test.ts": 125, + "src/main/skills/skill-cloud-grant-installation.test.ts": 15, + "src/main/skills/skill-cloud-install-target.test.ts": 6, + "src/main/skills/skill-cloud-request.test.ts": 58, + "src/main/skills/skill-cloud-service.test.ts": 73, + "src/main/skills/skill-delete/plan.test.ts": 102, + "src/main/skills/skill-delete/recovery.test.ts": 86, + "src/main/skills/skill-delete/service.test.ts": 618, + "src/main/skills/skill-delete/staging-visibility.test.ts": 21, + "src/main/skills/skill-delete/wsl-enumeration-protocol.test.ts": 12, + "src/main/skills/skill-discovery-concurrency.test.ts": 88, + "src/main/skills/skill-discovery-order.test.ts": 286, + "src/main/skills/skill-discovery-target.test.ts": 21, + "src/main/skills/skill-discovery-wsl-plugins.test.ts": 22, + "src/main/skills/skill-discovery-wsl-script-roundtrip.test.ts": 49, + "src/main/skills/skill-discovery-wsl.test.ts": 40, + "src/main/skills/skill-freshness-eligibility.test.ts": 10, + "src/main/skills/skill-freshness-inventory-limits.test.ts": 6, + "src/main/skills/skill-freshness-inventory.test.ts": 2182, + "src/main/skills/skill-git-tree-identity.test.ts": 40, + "src/main/skills/skill-install-destinations.test.ts": 26, + "src/main/skills/skill-install-discovery-verification.test.ts": 17, + "src/main/skills/skill-install-lock-release.test.ts": 7, + "src/main/skills/skill-install-lock.test.ts": 241, + "src/main/skills/skill-install-management-service.test.ts": 39, + "src/main/skills/skill-install-provenance.test.ts": 19, + "src/main/skills/skill-install-recovery.test.ts": 304, + "src/main/skills/skill-install-request-service.test.ts": 299, + "src/main/skills/skill-install-service.test.ts": 944, + "src/main/skills/skill-install-transaction.test.ts": 949, + "src/main/skills/skill-operation-observability.test.ts": 92, + "src/main/skills/skill-package-creation.test.ts": 312, + "src/main/skills/skill-package-deterministic-gzip.test.ts": 527, + "src/main/skills/skill-package-download.test.ts": 107, + "src/main/skills/skill-package-identity.test.ts": 86, + "src/main/skills/skill-package-tar.test.ts": 259, + "src/main/skills/skill-placement-alias-repair.test.ts": 43, + "src/main/skills/skill-placement-copy-drift.test.ts": 38, + "src/main/skills/skill-placement-reconciliation.test.ts": 74, + "src/main/skills/skill-placement-transaction.test.ts": 97, + "src/main/skills/skill-plugin-cache-scan.test.ts": 599, + "src/main/skills/skill-provider-destinations.test.ts": 8, + "src/main/skills/skill-provider-runtime-roots.test.ts": 9, + "src/main/skills/skill-remote-error-category-parity.test.ts": 15, + "src/main/skills/skill-remote-install-cancellation.test.ts": 4, + "src/main/skills/skill-remote-install-service.test.ts": 32, + "src/main/skills/skill-remove-transaction.test.ts": 577, + "src/main/skills/skill-root-file-walk.test.ts": 27, + "src/main/skills/skill-runtime-capability.test.ts": 9, + "src/main/skills/skill-scan-coalescer.test.ts": 12, + "src/main/skills/skill-share-preparation-service.test.ts": 237, + "src/main/skills/skill-ssh-relay-service.test.ts": 61, + "src/main/skills/skill-transaction-startup-recovery.test.ts": 206, + "src/main/skills/skill-update-convergence.test.ts": 32, + "src/main/skills/skill-update-outcome.test.ts": 49, + "src/main/skills/skill-update-registration.test.ts": 14, + "src/main/skills/skill-update-run.test.ts": 28, + "src/main/skills/skill-upload-session-admission-regression.test.ts": 24, + "src/main/skills/skill-upload-session-service.test.ts": 157, + "src/main/skills/skill-wsl-install-filesystem.test.ts": 9, + "src/main/skills/skill-wsl-provider-detection.test.ts": 10, + "src/main/source-control/forge-provider.test.ts": 52, + "src/main/source-control/hosted-review-azure-devops.integration.test.ts": 153, + "src/main/source-control/hosted-review-base-ref-suffix.test.ts": 12, + "src/main/source-control/hosted-review-bitbucket.integration.test.ts": 368, + "src/main/source-control/hosted-review-branch-cache.test.ts": 186, + "src/main/source-control/hosted-review-creation-eligibility.test.ts": 26, + "src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts": 14, + "src/main/source-control/hosted-review-creation-shared-symlinks.test.ts": 17, + "src/main/source-control/hosted-review-creation.test.ts": 29, + "src/main/source-control/hosted-review-dirty-preflight-wsl-paths.test.ts": 5, + "src/main/source-control/hosted-review-execution-host-routing.test.ts": 35, + "src/main/source-control/hosted-review-gitea.integration.test.ts": 321, + "src/main/source-control/hosted-review.test.ts": 14, + "src/main/source-control/pull-request-linked-issue.test.ts": 10, + "src/main/source-control/repo-default-branch.test.ts": 18, + "src/main/source-control/stacked-hosted-review-creation.test.ts": 7, + "src/main/speech/model-catalog.test.ts": 5, + "src/main/speech/model-manager-download-error.test.ts": 17, + "src/main/speech/model-manager-download-resume.test.ts": 3020, + "src/main/speech/model-manager-progress-callback.test.ts": 9, + "src/main/speech/model-manager-stream-cleanup.test.ts": 30, + "src/main/speech/model-manager-windows-path.test.ts": 584, + "src/main/speech/model-manager.test.ts": 45, + "src/main/speech/openai-api-key-store.test.ts": 18, + "src/main/speech/openai-transcription-client.test.ts": 5, + "src/main/speech/speech-model-deletion.test.ts": 8, + "src/main/speech/speech-model-download-response.test.ts": 7, + "src/main/speech/stt-offline-audio-chunker.test.ts": 164, + "src/main/speech/stt-service.test.ts": 24, + "src/main/speech/stt-worker-model-config.test.ts": 8, + "src/main/speech/stt-worker.test.ts": 7, + "src/main/sqlite/sqlite-read-failure.test.ts": 16, + "src/main/sqlite/sync-database.test.ts": 1184, + "src/main/ssh-expired-lease-pane-readoption.test.ts": 58, + "src/main/ssh-reattach-pane-cardinality.test.ts": 181, + "src/main/ssh/orcad-activation-gate.test.ts": 8, + "src/main/ssh/orcad-activation-record.test.ts": 9, + "src/main/ssh/orcad-remote-deploy.test.ts": 63, + "src/main/ssh/orcad-remote-gc.test.ts": 11, + "src/main/ssh/orcad-remote-launch.test.ts": 15, + "src/main/ssh/orcad-remote-rollback.test.ts": 61, + "src/main/ssh/orcad-remote-shell-commands.integration.test.ts": 1254, + "src/main/ssh/orcad-state-snapshot.test.ts": 7, + "src/main/ssh/orcad-update-plan.test.ts": 10, + "src/main/ssh/relay-native-dependency-coverage.test.ts": 333, + "src/main/ssh/relay-protocol-backpressure.test.ts": 31, + "src/main/ssh/relay-protocol.test.ts": 79, + "src/main/ssh/relay-socket-path-limit-shell.integration.test.ts": 77, + "src/main/ssh/relay-socket-path-limit.test.ts": 17, + "src/main/ssh/remote-install-coexistence.test.ts": 22, + "src/main/ssh/remote-install-model.test.ts": 13, + "src/main/ssh/removed-ssh-target-tombstone-retention.test.ts": 7, + "src/main/ssh/sftp-namespace-resolution.test.ts": 36, + "src/main/ssh/sftp-stream-late-error.test.ts": 27, + "src/main/ssh/sftp-upload.test.ts": 139, + "src/main/ssh/ssh-agent-identity-filter.test.ts": 14, + "src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts": 41, + "src/main/ssh/ssh-channel-multiplexer-saturation-wedge.test.ts": 14, + "src/main/ssh/ssh-channel-multiplexer-settlement.test.ts": 8, + "src/main/ssh/ssh-channel-multiplexer.test.ts": 52, + "src/main/ssh/ssh-config-alias-claim.test.ts": 12, + "src/main/ssh/ssh-config-host-picker.test.ts": 199, + "src/main/ssh/ssh-config-loader-regression.test.ts": 80, + "src/main/ssh/ssh-config-loader.test.ts": 77, + "src/main/ssh/ssh-config-parser-host-patterns.test.ts": 7, + "src/main/ssh/ssh-config-parser.test.ts": 262, + "src/main/ssh/ssh-config-resolver.test.ts": 12, + "src/main/ssh/ssh-connect-attempt-cancellation.test.ts": 7, + "src/main/ssh/ssh-connection-auth-fallback.test.ts": 125, + "src/main/ssh/ssh-connection-channel-open.test.ts": 746, + "src/main/ssh/ssh-connection-direct-startup.test.ts": 32, + "src/main/ssh/ssh-connection-file-transfer.test.ts": 17, + "src/main/ssh/ssh-connection-generation.test.ts": 9, + "src/main/ssh/ssh-connection-github-probe.test.ts": 16, + "src/main/ssh/ssh-connection-gssapi-fallback.test.ts": 127, + "src/main/ssh/ssh-connection-host-key-store-wiring.test.ts": 246, + "src/main/ssh/ssh-connection-host-key-verification.test.ts": 66, + "src/main/ssh/ssh-connection-manager-registry.test.ts": 67, + "src/main/ssh/ssh-connection-manager.test.ts": 9, + "src/main/ssh/ssh-connection-reconnect-ladder.test.ts": 335, + "src/main/ssh/ssh-connection-sftp-namespace.test.ts": 834, + "src/main/ssh/ssh-connection-sftp-wire.test.ts": 2413, + "src/main/ssh/ssh-connection-store.test.ts": 36, + "src/main/ssh/ssh-connection-system-transport.test.ts": 58, + "src/main/ssh/ssh-connection-utils.test.ts": 83, + "src/main/ssh/ssh-connection.test.ts": 72, + "src/main/ssh/ssh-control-socket.test.ts": 15, + "src/main/ssh/ssh-file-stream-inactivity-deadline.test.ts": 17, + "src/main/ssh/ssh-file-transfer-abort.test.ts": 16, + "src/main/ssh/ssh-g-config-resolution.test.ts": 67, + "src/main/ssh/ssh-git-response-stream-reader.test.ts": 11, + "src/main/ssh/ssh-git-stream-idle-timer.test.ts": 52, + "src/main/ssh/ssh-host-key-decision.test.ts": 19, + "src/main/ssh/ssh-host-key-store.test.ts": 134, + "src/main/ssh/ssh-host-key-verifier.test.ts": 21, + "src/main/ssh/ssh-known-hosts-source.test.ts": 57, + "src/main/ssh/ssh-known-hosts.test.ts": 23, + "src/main/ssh/ssh-multi-factor-authentication.test.ts": 1247, + "src/main/ssh/ssh-multi-key-authentication.test.ts": 30, + "src/main/ssh/ssh-multiplexer-transport-writer.test.ts": 26, + "src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts": 21, + "src/main/ssh/ssh-orphan-sweep-pane-state-verdicts.test.ts": 21, + "src/main/ssh/ssh-owner-recovery-retry.test.ts": 15, + "src/main/ssh/ssh-pending-pty-kill-replay.test.ts": 13, + "src/main/ssh/ssh-port-forward.test.ts": 25, + "src/main/ssh/ssh-port-scanner.test.ts": 31, + "src/main/ssh/ssh-posix-command-wrapper.test.ts": 27, + "src/main/ssh/ssh-provider-authority.test.ts": 18, + "src/main/ssh/ssh-proxy-command.test.ts": 15, + "src/main/ssh/ssh-pty-consumer-recovery.test.ts": 10, + "src/main/ssh/ssh-pty-consumer-session.test.ts": 13, + "src/main/ssh/ssh-pty-recovery-retention-budget.test.ts": 4, + "src/main/ssh/ssh-pty-retired-source-deliveries.test.ts": 11, + "src/main/ssh/ssh-reconnect-error-classification.test.ts": 10, + "src/main/ssh/ssh-reconnect-ladder.test.ts": 8, + "src/main/ssh/ssh-relay-build-toolchain.test.ts": 12, + "src/main/ssh/ssh-relay-cross-version-isolation.test.ts": 72, + "src/main/ssh/ssh-relay-deploy-helpers.test.ts": 296, + "src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts": 16, + "src/main/ssh/ssh-relay-deploy-staged-upload.test.ts": 166, + "src/main/ssh/ssh-relay-deploy.test.ts": 209, + "src/main/ssh/ssh-relay-endpoint-incumbent-shell.integration.test.ts": 2169, + "src/main/ssh/ssh-relay-endpoint-incumbent.test.ts": 15, + "src/main/ssh/ssh-relay-endpoint-takeover.test.ts": 15, + "src/main/ssh/ssh-relay-gc-retry.test.ts": 82, + "src/main/ssh/ssh-relay-install-lock.test.ts": 4, + "src/main/ssh/ssh-relay-install-namespace.test.ts": 16, + "src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts": 1273, + "src/main/ssh/ssh-relay-native-deps-cache-shell.test.ts": 323, + "src/main/ssh/ssh-relay-native-deps-cache.test.ts": 24, + "src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts": 659, + "src/main/ssh/ssh-relay-native-deps-install.test.ts": 13644, + "src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts": 254, + "src/main/ssh/ssh-relay-node-headers.test.ts": 2346, + "src/main/ssh/ssh-relay-node-pty-repair.test.ts": 15, + "src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts": 25, + "src/main/ssh/ssh-relay-orphan-abandon-paths.test.ts": 117, + "src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts": 1911, + "src/main/ssh/ssh-relay-reset.test.ts": 27, + "src/main/ssh/ssh-relay-sentinel-copy-budget.test.ts": 21, + "src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts": 596, + "src/main/ssh/ssh-relay-session-data-delivery.test.ts": 55, + "src/main/ssh/ssh-relay-session-incarnation.test.ts": 19, + "src/main/ssh/ssh-relay-session-managed-hooks.test.ts": 14, + "src/main/ssh/ssh-relay-session-model-migration.test.ts": 170, + "src/main/ssh/ssh-relay-session-orphan-sweep.test.ts": 43, + "src/main/ssh/ssh-relay-session-pending-kill-replay.test.ts": 14, + "src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts": 120, + "src/main/ssh/ssh-relay-session-recovery-durability.test.ts": 1066, + "src/main/ssh/ssh-relay-session-recovery-races.test.ts": 325, + "src/main/ssh/ssh-relay-session-rejected-delivery.test.ts": 793, + "src/main/ssh/ssh-relay-session-relay-loss.test.ts": 37, + "src/main/ssh/ssh-relay-session-terminal-error.test.ts": 29, + "src/main/ssh/ssh-relay-session.test.ts": 311, + "src/main/ssh/ssh-relay-sftp-namespace-install.test.ts": 198, + "src/main/ssh/ssh-relay-superseded-endpoints.test.ts": 17, + "src/main/ssh/ssh-relay-upload-stage-commands.test.ts": 39445, + "src/main/ssh/ssh-relay-versioned-install.test.ts": 85, + "src/main/ssh/ssh-remote-cli-dispatch-refusal-passthrough.test.ts": 16, + "src/main/ssh/ssh-remote-cli-format.test.ts": 7, + "src/main/ssh/ssh-remote-cli-host-passthrough.test.ts": 39, + "src/main/ssh/ssh-remote-cli-launcher.test.ts": 10, + "src/main/ssh/ssh-remote-cli-terminal-host-scope.test.ts": 20, + "src/main/ssh/ssh-remote-commands.test.ts": 11428, + "src/main/ssh/ssh-remote-linear-activity-output.test.ts": 4, + "src/main/ssh/ssh-remote-linear-cli.test.ts": 53, + "src/main/ssh/ssh-remote-linear-list-issues.test.ts": 14, + "src/main/ssh/ssh-remote-linear-relation-write.test.ts": 15, + "src/main/ssh/ssh-remote-linear-save-issue.test.ts": 24, + "src/main/ssh/ssh-remote-linear-truncation-output.test.ts": 7, + "src/main/ssh/ssh-remote-node-resolution.test.ts": 118, + "src/main/ssh/ssh-remote-node-toolchain-probe.test.ts": 8, + "src/main/ssh/ssh-remote-orca-cli.test.ts": 140, + "src/main/ssh/ssh-remote-orchestration-compatibility.test.ts": 289, + "src/main/ssh/ssh-remote-orchestration-send.test.ts": 8, + "src/main/ssh/ssh-remote-platform-detection.test.ts": 27, + "src/main/ssh/ssh-remote-platform.test.ts": 16, + "src/main/ssh/ssh-remote-powershell.test.ts": 72, + "src/main/ssh/ssh-remote-windows-command-line-limit.test.ts": 23, + "src/main/ssh/ssh-request-outcome-verdict.test.ts": 5, + "src/main/ssh/ssh-security-key-identity.test.ts": 41, + "src/main/ssh/ssh-session-limit-error.test.ts": 5, + "src/main/ssh/ssh-system-fallback.test.ts": 90, + "src/main/ssh/ssh-system-transport.integration.test.ts": 637, + "src/main/ssh/ssh-target-id-migration.test.ts": 6, + "src/main/ssh/ssh-target-readoption.test.ts": 10, + "src/main/ssh/system-ssh-binary.test.ts": 12, + "src/main/ssh/system-ssh-dynamic-forward-process.test.ts": 61, + "src/main/ssh/system-ssh-forward-process.test.ts": 40, + "src/main/ssh/system-ssh-sftp-args.test.ts": 8, + "src/main/ssh/system-ssh-sftp-path.test.ts": 11, + "src/main/ssh/system-ssh-windows-upload.test.ts": 112, + "src/main/ssh/system-ssh-windows-write-capabilities.test.ts": 8, + "src/main/ssh/vscode-ssh-authority.test.ts": 8, + "src/main/star-nag/service-direct-star.test.ts": 14, + "src/main/star-nag/service-force-show-races.test.ts": 17, + "src/main/star-nag/service-outcome-telemetry.test.ts": 11, + "src/main/star-nag/service-prompt-moments.test.ts": 18, + "src/main/star-nag/service-threshold-prompt.test.ts": 15, + "src/main/startup/bootstrap-fatal-exit-guard.test.ts": 6, + "src/main/startup/branch-rename-hook-structured-session.test.ts": 10, + "src/main/startup/cli-launch-redirect.test.ts": 11, + "src/main/startup/configure-process-dev-parent-shutdown.test.ts": 65, + "src/main/startup/configure-process.test.ts": 123, + "src/main/startup/desktop-startup-ordering.test.ts": 14, + "src/main/startup/dev-education-suppression.test.ts": 10, + "src/main/startup/dev-instance-identity.test.ts": 7, + "src/main/startup/ensure-virtual-display.test.ts": 5092, + "src/main/startup/first-window-startup-services.test.ts": 16, + "src/main/startup/gpu-fallback-marker.test.ts": 7, + "src/main/startup/gpu-fallback-switches.test.ts": 6, + "src/main/startup/gpu-lifecycle-install-dir-acl-guard.test.ts": 55, + "src/main/startup/headless-pty-hydration-ordering.test.ts": 10, + "src/main/startup/host-port-bootstrap-wiring.test.ts": 11, + "src/main/startup/hydrate-shell-path.test.ts": 228, + "src/main/startup/hydrate-shell-path.windows.test.ts": 284, + "src/main/startup/legacy-worker-renderer-recovery.test.ts": 16, + "src/main/startup/login-shell-environment.test.ts": 67, + "src/main/startup/main-process-error-guards.test.ts": 29, + "src/main/startup/main-process-ready-phase-ordering.test.ts": 9, + "src/main/startup/main-window-structured-status-filter.test.ts": 7, + "src/main/startup/os-opened-markdown-delivery.test.ts": 10, + "src/main/startup/os-opened-markdown-files.test.ts": 116, + "src/main/startup/os-opened-markdown-wiring.test.ts": 5, + "src/main/startup/pre-gone-crash-sampling-wiring.test.ts": 5, + "src/main/startup/renderer-heap-headroom.test.ts": 10, + "src/main/startup/run-electron-vite-dev-web.test.ts": 432, + "src/main/startup/run-electron-vite-dev.test.ts": 943, + "src/main/startup/secret-protection-report-deferral-wiring.test.ts": 6, + "src/main/startup/secure-dns-census.test.ts": 407, + "src/main/startup/serve-desktop-activation-wiring.test.ts": 9, + "src/main/startup/serve-desktop-activation.test.ts": 5, + "src/main/startup/serve-mode-argv-cli-redirect-order.test.ts": 9, + "src/main/startup/serve-mode-argv.test.ts": 32, + "src/main/startup/serve-options.test.ts": 20, + "src/main/startup/serve-signal-handlers.test.ts": 4, + "src/main/startup/single-instance-lock-exit.electron.test.ts": 275, + "src/main/startup/single-instance-lock-headless-exit.test.ts": 11, + "src/main/startup/single-instance-lock.test.ts": 19, + "src/main/startup/skill-share-deep-link-state.test.ts": 8, + "src/main/startup/startup-diagnostics.test.ts": 7, + "src/main/startup/window-all-closed-quit-policy.test.ts": 5, + "src/main/startup/windows-desktop-shell-path-startup.test.ts": 8, + "src/main/startup/windows-install-dir-acl-probe.test.ts": 20, + "src/main/startup/windows-install-dir-acl-recovery.test.ts": 460, + "src/main/startup/windows-install-dir-acl-startup-wiring.test.ts": 6, + "src/main/startup/windows-install-dir-package-acl-repair.test.ts": 43, + "src/main/startup/windows-shell-path-hydration.test.ts": 315, + "src/main/startup/windows-shell-path-ownership.test.ts": 10, + "src/main/startup/windows-user-data-acl.test.ts": 15, + "src/main/startup/wsl-cli-reconciliation-startup-barrier.test.ts": 9, + "src/main/stats/agent-session-transition-recorder.test.ts": 23, + "src/main/stats/collector-async-save.test.ts": 375, + "src/main/stats/stats-title-detection-independence.test.ts": 6, + "src/main/synthetic-title-frame-routing.test.ts": 4, + "src/main/synthetic-title-spinner.test.ts": 4, + "src/main/synthetic-title-visibility.test.ts": 5, + "src/main/system-fonts.test.ts": 16, + "src/main/system-power-lifecycle.test.ts": 8, + "src/main/system-resume-broadcast.test.ts": 24, + "src/main/telemetry/burst-cap.test.ts": 27, + "src/main/telemetry/classify-error.test.ts": 7, + "src/main/telemetry/client-lifecycle.test.ts": 17, + "src/main/telemetry/client.test.ts": 42, + "src/main/telemetry/cohort-classifier.test.ts": 13, + "src/main/telemetry/consent.test.ts": 15, + "src/main/telemetry/install-id.test.ts": 7, + "src/main/telemetry/onboarding-cohort-classifier.test.ts": 12, + "src/main/telemetry/onboarding-feature-setup-validator.test.ts": 18, + "src/main/telemetry/validator-warn-cache.test.ts": 30, + "src/main/telemetry/validator.test.ts": 20, + "src/main/terminal-history-async-delete.test.ts": 289, + "src/main/terminal-history-gc-fs-call-count.test.ts": 54, + "src/main/terminal-history-gc.test.ts": 877, + "src/main/terminal-history-tombstone-retry.test.ts": 4798, + "src/main/terminal-history.test.ts": 34, + "src/main/text-generation/agent-failure-output.test.ts": 11, + "src/main/text-generation/commit-message-agent-environment.test.ts": 31, + "src/main/text-generation/commit-message-command-backslash-mode.test.ts": 6, + "src/main/text-generation/commit-message-text-generation-branch-name.test.ts": 16, + "src/main/text-generation/commit-message-text-generation-cancellation.test.ts": 439, + "src/main/text-generation/commit-message-text-generation-failure-sanitization.test.ts": 15, + "src/main/text-generation/commit-message-text-generation-generated-output.test.ts": 15, + "src/main/text-generation/commit-message-text-generation-linked-issue.test.ts": 14, + "src/main/text-generation/commit-message-text-generation-local-subprocess.test.ts": 28, + "src/main/text-generation/commit-message-text-generation-model-discovery.test.ts": 48, + "src/main/text-generation/commit-message-text-generation-regression.test.ts": 10, + "src/main/text-generation/commit-message-text-generation-remote-execution.test.ts": 30, + "src/main/text-generation/commit-message-text-generation-settings.test.ts": 19, + "src/main/text-generation/pull-request-context-errors.test.ts": 25, + "src/main/text-generation/pull-request-context.test.ts": 30, + "src/main/tray/system-tray.test.ts": 92, + "src/main/tray/tray-attention-icon.test.ts": 13, + "src/main/tray/tray-dev-badge.test.ts": 11, + "src/main/update-install-exit-watchdog.test.ts": 12, + "src/main/updater-changelog.test.ts": 7, + "src/main/updater-events.test.ts": 204, + "src/main/updater-lifecycle-diagnostics.test.ts": 5, + "src/main/updater-linux-package-recovery-actions.test.ts": 2079, + "src/main/updater-nudge.test.ts": 12, + "src/main/updater-prerelease-feed-readiness.test.ts": 114, + "src/main/updater-prerelease-feed.test.ts": 148, + "src/main/updater-release-builds.test.ts": 30, + "src/main/updater-test-harness.leaked-timers.test.ts": 2596, + "src/main/updater-test-module-loader.test.ts": 508, + "src/main/updater.build-channel-selection.test.ts": 1674, + "src/main/updater.check-failure.test.ts": 1910, + "src/main/updater.check-preflight.test.ts": 2719, + "src/main/updater.check-settlement.test.ts": 2274, + "src/main/updater.fallback.test.ts": 22, + "src/main/updater.headless-serve-install.test.ts": 272, + "src/main/updater.install-failure-cause.test.ts": 2517, + "src/main/updater.linux-externally-managed.test.ts": 1400, + "src/main/updater.linux-root-package-install.test.ts": 1772, + "src/main/updater.nudge-campaign.test.ts": 1490, + "src/main/updater.prerelease-fallback.test.ts": 4396, + "src/main/updater.publishing-window-feed.test.ts": 3071, + "src/main/updater.quit-and-install.test.ts": 2199, + "src/main/updater.startup-scheduling.test.ts": 1573, + "src/main/usage-worktree-canonicalizer.test.ts": 10, + "src/main/usage-worktree-metadata.test.ts": 9, + "src/main/usage/highest-usage-key.test.ts": 14, + "src/main/usage/usage-breakdown-scaling.test.ts": 55, + "src/main/usage/usage-calendar-range.test.ts": 8, + "src/main/usage/usage-provider-store-lifecycle.test.ts": 293, + "src/main/usage/usage-worktree-refs.test.ts": 7, + "src/main/warp-themes/discovery.test.ts": 30, + "src/main/warp-themes/index.test.ts": 447, + "src/main/warp-themes/manual-warp-theme-files.test.ts": 36, + "src/main/warp-themes/parser-runner.test.ts": 9, + "src/main/warp-themes/parser.test.ts": 23, + "src/main/warp-themes/theme-file-scanner.test.ts": 22, + "src/main/win32-utils-async-acl.test.ts": 16, + "src/main/win32-utils.test.ts": 12, + "src/main/window/attach-main-window-services.test.ts": 195, + "src/main/window/clipboard-dashboard-popout-access.test.ts": 15, + "src/main/window/clipboard-file-copy.test.ts": 11, + "src/main/window/clipboard-image-temp-file.test.ts": 8, + "src/main/window/clipboard-image-thumbnail.test.ts": 8, + "src/main/window/clipboard-ipc-handlers.test.ts": 94, + "src/main/window/clipboard-remote-file-copy.test.ts": 18, + "src/main/window/clipboard-remote-file-staging-fs.test.ts": 23, + "src/main/window/clipboard-remote-file-staging.test.ts": 37, + "src/main/window/clipboard-runtime-owned-ssh-paste.test.ts": 15, + "src/main/window/clipboard-text-write-verify.test.ts": 8, + "src/main/window/clipboard-windows-image-file.test.ts": 17, + "src/main/window/createMainWindow-close-confirmation.test.ts": 29, + "src/main/window/createMainWindow-markdown-editor-focus.test.ts": 21, + "src/main/window/createMainWindow-recovery-reload-watchdog.test.ts": 48, + "src/main/window/createMainWindow-renderer-crash-recovery.test.ts": 51, + "src/main/window/createMainWindow-startup-reveal.test.ts": 35, + "src/main/window/createMainWindow-system-resume-relay.test.ts": 11, + "src/main/window/createMainWindow-terminal-focus-shortcuts.test.ts": 46, + "src/main/window/createMainWindow-tray-minimize-close.test.ts": 31, + "src/main/window/createMainWindow-zoom-and-tab-switch-shortcuts.test.ts": 17, + "src/main/window/createMainWindow.test.ts": 35, + "src/main/window/dashboard-popout-window.test.ts": 26, + "src/main/window/editable-context-menu.test.ts": 20, + "src/main/window/focus-existing-window.test.ts": 10, + "src/main/window/foreground-activation-policy.test.ts": 15, + "src/main/window/history-gc-profile-worktree-ids.test.ts": 13, + "src/main/window/history-gc-worktree-ids.test.ts": 8, + "src/main/window/macos-app-activation.test.ts": 5, + "src/main/window/macos-tahoe-release.test.ts": 6, + "src/main/window/main-window-visibility.test.ts": 5, + "src/main/window/main-window-webview-security.test.ts": 12, + "src/main/window/mobile-markdown-request-relay.test.ts": 26, + "src/main/window/privileged-window-navigation.test.ts": 14, + "src/main/window/renderer-document-navigation.test.ts": 9, + "src/main/window/renderer-publication-throttle.test.ts": 5, + "src/main/window/renderer-recovery-prompt.test.ts": 21, + "src/main/window/renderer-recovery-reload-watchdog.test.ts": 16, + "src/main/window/runtime-renderer-notification-sender.test.ts": 9, + "src/main/window/session-tab-close-request-relay.test.ts": 40, + "src/main/window/terminal-tab-close-request-relay.test.ts": 24, + "src/main/window/updater-package-recovery-ipc.test.ts": 14, + "src/main/window/window-close-decision.test.ts": 4, + "src/main/windows-descendant-exit-verification.test.ts": 17, + "src/main/windows-process-tree-kill.test.ts": 7, + "src/main/windows-pty-root-identity.test.ts": 13, + "src/main/windows/windows-command-line-recovery-health.test.ts": 10, + "src/main/windows/windows-process-table-cim-scan.test.ts": 6, + "src/main/windows/windows-process-table.test.ts": 64, + "src/main/windows/windows-process-tree-command-line-patch.test.ts": 11, + "src/main/windows/windows-pty-job.test.ts": 14, + "src/main/workspace-cleanup-removal-snapshot-prune.test.ts": 13, + "src/main/workspace-cleanup-scan-snapshot.test.ts": 88, + "src/main/workspace-space-analysis-capacity.test.ts": 38, + "src/main/workspace-space-analysis-du-timeout.test.ts": 236, + "src/main/workspace-space-analysis-snapshot.test.ts": 66, + "src/main/workspace-space-analysis.test.ts": 227, + "src/main/workspace-space-repo-scan.test.ts": 5, + "src/main/workspace-space-scan-control.test.ts": 36, + "src/main/worktree-create-base-prefetch.test.ts": 225, + "src/main/worktree-create-base.test.ts": 5, + "src/main/worktree-create-candidates.test.ts": 5, + "src/main/worktree-create-execution-host-route.test.ts": 10, + "src/main/worktree-create-preparation-cancellation.test.ts": 29, + "src/main/worktree-create-preparation-claim.test.ts": 8, + "src/main/worktree-create-preparation-wsl-root.test.ts": 13, + "src/main/worktree-create-preparation.test.ts": 110, + "src/main/worktree-create-timing.test.ts": 7, + "src/main/worktree-identity-persistence.test.ts": 207, + "src/main/worktree-lineage-pruning.test.ts": 12, + "src/main/worktree-metadata-ownership.test.ts": 4, + "src/main/worktree-name-retirement.test.ts": 18, + "src/main/worktree-removal-authority.test.ts": 10, + "src/main/worktree-removal-execution-host-route.test.ts": 6, + "src/main/worktree-removal-repo-owner.test.ts": 7, + "src/main/worktree-removal-safety.test.ts": 18, + "src/main/worktree-removal-session-partition-fencing.test.ts": 1223, + "src/main/worktree-retirement-backfill-scan.test.ts": 20, + "src/main/worktree-retirement-backfill-stall.test.ts": 15, + "src/main/worktree-retirement-discovery-wsl.test.ts": 32, + "src/main/worktree-retirement-discovery.test.ts": 37, + "src/main/worktree-retirement-namespace.test.ts": 28, + "src/main/worktree-root-preparation.test.ts": 11, + "src/main/worktree-trash.test.ts": 53, + "src/main/wsl-availability-missing-kernel.test.ts": 10, + "src/main/wsl-bash-command.test.ts": 4, + "src/main/wsl-distro-list-output.test.ts": 8, + "src/main/wsl-distro-list-single-flight.test.ts": 14, + "src/main/wsl-fish-history-cleanup.test.ts": 15, + "src/main/wsl-interop-spawn-directory.test.ts": 10, + "src/main/wsl-running-distros.test.ts": 125, + "src/main/wsl-unc-delete-symlink-repro.test.ts": 7, + "src/main/wsl-unc-delete.test.ts": 12, + "src/main/wsl.test.ts": 35, + "src/main/wsl/wsl-guest-environment.test.ts": 52, + "src/main/wsl/wsl-invocation-boundary.test.ts": 6, + "src/main/wsl/wsl-probe-failure-semantics.test.ts": 587, + "src/main/wsl/wsl-runner.test.ts": 32, + "src/main/wsl/wsl-w1-w3-contract.test.ts": 26, + "src/preload/api/platform-bridge.test.ts": 12, + "src/preload/app-restart-checkpoint-routing.test.ts": 651, + "src/preload/browser-client-page-renderer-requests.test.ts": 22, + "src/preload/browser-find-subscriptions.test.ts": 11, + "src/preload/browser-window-close.test.ts": 6, + "src/preload/close-active-tab-payload-admission.test.ts": 5, + "src/preload/doc-preview-link-interception.test.ts": 29, + "src/preload/pty-snapshot-capability-ipc.test.ts": 652, + "src/preload/renderer-heap-statistics-reader.test.ts": 5, + "src/preload/renderer-process-memory-reader.test.ts": 7, + "src/preload/renderer-restart-wiring.test.ts": 13, + "src/preload/runtime-environment-subscriptions.test.ts": 11, + "src/preload/ssh-authority-forwarding.test.ts": 682, + "src/preload/updater-package-recovery.test.ts": 273, + "src/preload/usage-provider-api.test.ts": 9, + "src/relay/agent-exec-handler-windows.test.ts": 11, + "src/relay/agent-exec-handler.test.ts": 28, + "src/relay/agent-hook-envelope-publication.test.ts": 215, + "src/relay/agent-hook-integration.test.ts": 116, + "src/relay/agent-hook-retired-pane-suppression.test.ts": 125, + "src/relay/agent-hook-server-codex-subagent-transcript.test.ts": 1068, + "src/relay/agent-hook-server.test.ts": 1481, + "src/relay/ai-vault-handler.test.ts": 208, + "src/relay/ai-vault-service-client.test.ts": 28, + "src/relay/ai-vault-service-restart-policy.test.ts": 10, + "src/relay/ai-vault-service-spawn.test.ts": 9, + "src/relay/context.test.ts": 5, + "src/relay/dispatcher-capacity-degradation.test.ts": 112, + "src/relay/dispatcher-client-close-cause.test.ts": 15, + "src/relay/dispatcher-client-writer.test.ts": 13, + "src/relay/dispatcher-frame-guard-regressions.test.ts": 7, + "src/relay/dispatcher-json-payload.test.ts": 51, + "src/relay/dispatcher-notification-ownership.test.ts": 5, + "src/relay/dispatcher-silent-client-reaper.test.ts": 19, + "src/relay/dispatcher-structured-error.test.ts": 10, + "src/relay/dispatcher-timeout.test.ts": 19, + "src/relay/dispatcher-writer-admission.test.ts": 94, + "src/relay/dispatcher.test.ts": 198, + "src/relay/external-automation-provider-catalog.test.ts": 27, + "src/relay/external-automations-handler-log-path.test.ts": 24, + "src/relay/external-automations-handler.test.ts": 21, + "src/relay/fs-handler-doc-preview.test.ts": 16, + "src/relay/fs-handler-file-range-dispatch.test.ts": 79, + "src/relay/fs-handler-file-range.test.ts": 61, + "src/relay/fs-handler-git-search.test.ts": 7, + "src/relay/fs-handler-install-rg.test.ts": 9, + "src/relay/fs-handler-list-files-cancel.test.ts": 28, + "src/relay/fs-handler-list-files-ignored.test.ts": 648, + "src/relay/fs-handler-list-files-result-limit.test.ts": 12, + "src/relay/fs-handler-readdir-fallback.test.ts": 8, + "src/relay/fs-handler-ripgrep-fallback.test.ts": 19, + "src/relay/fs-handler-stream.test.ts": 475, + "src/relay/fs-handler.test.ts": 172, + "src/relay/fs-list-files-cancel.integration.test.ts": 26, + "src/relay/fs-list-files-large-response.integration.test.ts": 87, + "src/relay/fs-list-files-scan-coordinator.test.ts": 14, + "src/relay/fs-path-metadata-requests.test.ts": 10, + "src/relay/fs-path-metadata-symlink-concurrency.test.ts": 11, + "src/relay/fs-search-line-fragments.test.ts": 84, + "src/relay/fs-stream-pty-echo-backpressure.integration.test.ts": 135, + "src/relay/git-branch-delete-refusal-parity.test.ts": 10, + "src/relay/git-exec-validator.test.ts": 18, + "src/relay/git-handler-blob-readers.test.ts": 9, + "src/relay/git-handler-branch-cleanup.test.ts": 19, + "src/relay/git-handler-branch-compare.test.ts": 58, + "src/relay/git-handler-branch-diff-equivalence.test.ts": 908, + "src/relay/git-handler-branch-diff.test.ts": 174, + "src/relay/git-handler-check-ignore.test.ts": 8, + "src/relay/git-handler-diff-read-coalescing.test.ts": 103, + "src/relay/git-handler-file-diff.test.ts": 1278, + "src/relay/git-handler-fork-remote-exec.test.ts": 75, + "src/relay/git-handler-pull-reconciliation.test.ts": 712, + "src/relay/git-handler-push-target.test.ts": 16, + "src/relay/git-handler-remote-sync.test.ts": 1241, + "src/relay/git-handler-staging.test.ts": 402, + "src/relay/git-handler-status-ops.test.ts": 63, + "src/relay/git-handler-submodule-cache-invalidation.test.ts": 8, + "src/relay/git-handler-submodule-ops.test.ts": 47, + "src/relay/git-handler-submodule-status-cancellation.test.ts": 11, + "src/relay/git-handler-termination.test.ts": 9, + "src/relay/git-handler-utils.test.ts": 4, + "src/relay/git-handler-working-tree-changes.test.ts": 1001, + "src/relay/git-handler-worktree-clean.test.ts": 9, + "src/relay/git-handler-worktree-git-capabilities.test.ts": 12, + "src/relay/git-handler-worktree-inspection.test.ts": 819, + "src/relay/git-handler-worktree-list-authority.test.ts": 12, + "src/relay/git-handler-worktree-list.test.ts": 11, + "src/relay/git-handler-worktree-ops.test.ts": 27, + "src/relay/git-handler-worktree-paths.test.ts": 5, + "src/relay/git-handler-worktree-provisioning.test.ts": 723, + "src/relay/git-handler.test.ts": 638, + "src/relay/git-porcelain-local-parity.test.ts": 21, + "src/relay/git-push-target-local-parity.test.ts": 21, + "src/relay/git-response-pty-echo-backpressure.integration.test.ts": 751, + "src/relay/git-response-stream-ownership.test.ts": 18, + "src/relay/git-status-branch-line-total.test.ts": 197, + "src/relay/git-status-upstream-negative-cache.test.ts": 260, + "src/relay/git-stdout-stream.test.ts": 21, + "src/relay/git-working-file-read.test.ts": 27, + "src/relay/hermes-run-correlation.test.ts": 8, + "src/relay/hermes-run-history.test.ts": 13, + "src/relay/integration.test.ts": 1222, + "src/relay/legacy-relay-publication-ledger.test.ts": 7, + "src/relay/managed-hook-installer.test.ts": 13, + "src/relay/node-pty-binding-survey.test.ts": 132, + "src/relay/node-pty-unavailable-diagnosis.test.ts": 13, + "src/relay/plugin-overlay-env.test.ts": 14, + "src/relay/plugin-overlay.test.ts": 24, + "src/relay/plugin-source-limit.test.ts": 8, + "src/relay/port-scan-handler.test.ts": 70, + "src/relay/preflight-handler.test.ts": 18, + "src/relay/protocol-backpressure.test.ts": 51, + "src/relay/protocol-handshake.test.ts": 7, + "src/relay/protocol-json-payload.test.ts": 178, + "src/relay/pty-handler-attach-replay.test.ts": 49, + "src/relay/pty-handler-dispose-lifecycle.test.ts": 37, + "src/relay/pty-handler-grace-timer.test.ts": 19, + "src/relay/pty-handler-inventory-process-evidence.test.ts": 30, + "src/relay/pty-handler-output-drain-differential.test.ts": 135, + "src/relay/pty-handler-output-streaming.test.ts": 420, + "src/relay/pty-handler-ownership-attestation.test.ts": 87, + "src/relay/pty-handler-resize-stale-pty.test.ts": 30, + "src/relay/pty-handler-retired-pane-surface.test.ts": 82, + "src/relay/pty-handler-revive.test.ts": 91, + "src/relay/pty-handler-shell-resolution.test.ts": 33, + "src/relay/pty-handler-shutdown-signals.test.ts": 54, + "src/relay/pty-handler-source-publication.test.ts": 260, + "src/relay/pty-handler-spawn-admission.test.ts": 127, + "src/relay/pty-handler-spawn-cwd.test.ts": 46, + "src/relay/pty-handler-spawn-environment.test.ts": 108, + "src/relay/pty-handler-startup-command-delivery.test.ts": 111, + "src/relay/pty-handler-windows-child-process-evidence.test.ts": 35, + "src/relay/pty-replay-buffer-equivalence.test.ts": 77, + "src/relay/pty-shell-launch.test.ts": 312, + "src/relay/pty-shell-utils.test.ts": 58, + "src/relay/pty-source-credit-ledger.test.ts": 122, + "src/relay/pty-source-credit-scheduler.test.ts": 12, + "src/relay/pty-source-sent-boundaries.test.ts": 88, + "src/relay/relay-command-env.test.ts": 14, + "src/relay/relay-daemon-fatal-reap.test.ts": 540, + "src/relay/relay-diagnostic-log.test.ts": 7, + "src/relay/relay-endpoint-credential-publication.test.ts": 2634, + "src/relay/relay-filesystem-watch-registry.test.ts": 128, + "src/relay/relay-grace-branch.test.ts": 7, + "src/relay/relay-handshake-roundtrip.test.ts": 317, + "src/relay/relay-launch-options.test.ts": 12, + "src/relay/relay-oversized-notification-survival.test.ts": 67, + "src/relay/relay-pty-consumer-owner-displacement.test.ts": 19, + "src/relay/relay-pty-publication-admission.test.ts": 26, + "src/relay/relay-pty-source-cancellation-exit.test.ts": 54, + "src/relay/relay-pty-source-exit-publication.test.ts": 27, + "src/relay/relay-pty-source-publication.test.ts": 69, + "src/relay/relay-pty-source-recovery-completion.test.ts": 9, + "src/relay/relay-pty-source-recovery-interleavings.test.ts": 25, + "src/relay/relay-pty-source-recovery-window.test.ts": 33, + "src/relay/relay-pty-source-restore-retry.test.ts": 15, + "src/relay/relay-pty-source-send-scheduler.test.ts": 9, + "src/relay/relay-pty-source-superseded-activation.test.ts": 21, + "src/relay/relay-reconnect-listener-credential-gate.test.ts": 28, + "src/relay/relay-watch-root-capacity.test.ts": 15, + "src/relay/relay-watcher-event-emitter.test.ts": 842, + "src/relay/relay-watcher-frame-chunking.test.ts": 632, + "src/relay/relay-watcher-parent-removal.test.ts": 20, + "src/relay/relay-watcher-pending-setup-waiters.test.ts": 233, + "src/relay/relay-watcher-setup-wait.test.ts": 320, + "src/relay/remote-artifact-cli-input.test.ts": 124, + "src/relay/remote-cli-env.test.ts": 5, + "src/relay/remote-cli-stdin.test.ts": 7, + "src/relay/remote-cli-timeout.test.ts": 9, + "src/relay/retired-pane-surfaces.test.ts": 7, + "src/relay/rotating-log-writer.test.ts": 33, + "src/relay/skill-install-handler.test.ts": 306, + "src/relay/skill-upload-multi-relay.integration.test.ts": 549, + "src/relay/ssh-pty-consumer-session-adapter.test.ts": 48, + "src/relay/ssh-pty-source-credit-adapter.test.ts": 29, + "src/relay/subprocess-tree-termination.test.ts": 6, + "src/relay/subprocess.test.ts": 22239, + "src/relay/terminal-history-wsl.test.ts": 11, + "src/relay/terminal-history.test.ts": 14, + "src/relay/windows-port-scan.test.ts": 66, + "src/relay/workspace-session-handler.test.ts": 12, + "src/relay/workspace-snapshot-publication.test.ts": 29, + "src/relay/workspace-space-scan-du-capacity.test.ts": 34, + "src/relay/workspace-space-scan.test.ts": 37, + "src/relay/wsl-agent-hook-relay.test.ts": 45, + "src/relay/wsl-hook-fs-bridge.test.ts": 11, + "src/relay/wsl-install-plugins-handler.test.ts": 20, + "src/renderer/src/app-shell/app-command-handlers-tab-rename.test.ts": 13, + "src/renderer/src/app-shell/app-command-handlers-workspace-delete.test.ts": 10, + "src/renderer/src/app-shell/app-root-surface-settings.test.tsx": 25, + "src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.test.ts": 6, + "src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx": 92, + "src/renderer/src/app-shell/shutdown-checkpoint-persist.test.ts": 16, + "src/renderer/src/app-shell/shutdown-checkpoint-restart-lifecycle.test.ts": 20, + "src/renderer/src/app-shell/startup-actions-selector.test.ts": 56, + "src/renderer/src/app-shell/use-document-appearance.test.tsx": 33, + "src/renderer/src/app-shell/window-visibility-actions-selector.test.ts": 42, + "src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx": 120, + "src/renderer/src/app-startup-routing.test.ts": 13, + "src/renderer/src/assets/mobile-page-qr-layout.test.ts": 5, + "src/renderer/src/assets/rich-markdown-task-list-style.test.ts": 4, + "src/renderer/src/assets/terminal-container-geometry.test.ts": 7, + "src/renderer/src/assets/terminal-scrollbar-style.test.ts": 4, + "src/renderer/src/assets/worktree-card-active-style.test.ts": 4, + "src/renderer/src/components/AgentStateDot.test.ts": 38, + "src/renderer/src/components/AgentWorkingSpinner.test.tsx": 43, + "src/renderer/src/components/LinuxPackageInstallRecoveryCard.test.tsx": 343, + "src/renderer/src/components/NewWorkspaceComposerCard.set-location-warm.test.tsx": 141, + "src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx": 229, + "src/renderer/src/components/NewWorkspaceComposerCard.test.tsx": 1044, + "src/renderer/src/components/QuickOpen.mount-gating.test.tsx": 647, + "src/renderer/src/components/SelectedTextCopyMenu.test.tsx": 49, + "src/renderer/src/components/StarNagCard.test.tsx": 60, + "src/renderer/src/components/StateIndicatorTooltip.test.tsx": 12, + "src/renderer/src/components/TerminalSearch.test.tsx": 106, + "src/renderer/src/components/TerminalTitlebarTabs.test.tsx": 28, + "src/renderer/src/components/TerminalWorkbenchContainer.test.tsx": 40, + "src/renderer/src/components/UpdateCard.error-card.test.tsx": 449, + "src/renderer/src/components/UpdateCard.test.ts": 60, + "src/renderer/src/components/WorktreeBaseFallbackDialog.test.tsx": 146, + "src/renderer/src/components/WorktreeJumpPalette.linear-url.test.tsx": 865, + "src/renderer/src/components/WorktreeJumpPalette.mount-gating.test.tsx": 489, + "src/renderer/src/components/WorktreeJumpPalette.recent-tabs.behavior.test.tsx": 466, + "src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx": 907, + "src/renderer/src/components/WorktreeJumpPalette.test.tsx": 423, + "src/renderer/src/components/activity/ActivityPrototypePage.filter-focus-shortcut.test.ts": 8, + "src/renderer/src/components/activity/ActivityPrototypePage.test.ts": 21, + "src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts": 31, + "src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx": 388, + "src/renderer/src/components/activity/activity-auto-mark-read-loop.react185.test.tsx": 950, + "src/renderer/src/components/activity/activity-clear-completed.test.ts": 28, + "src/renderer/src/components/activity/activity-event-builder-agent-context.test.ts": 13, + "src/renderer/src/components/activity/activity-event-builder.bounded-history.test.ts": 12, + "src/renderer/src/components/activity/activity-event-builder.host-ownership.test.ts": 17, + "src/renderer/src/components/activity/activity-event-builder.identity-reuse.test.ts": 15, + "src/renderer/src/components/activity/activity-event-builder.live-cap.test.ts": 14, + "src/renderer/src/components/activity/activity-portal-churn-budget.test.ts": 8, + "src/renderer/src/components/activity/activity-portal-readiness-decay.react185.test.tsx": 33, + "src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx": 144, + "src/renderer/src/components/activity/activity-portal-readiness-oscillation.test.ts": 5, + "src/renderer/src/components/activity/activity-portal-readiness-subscription-churn.react185.test.tsx": 131, + "src/renderer/src/components/activity/activity-portal-thread-reconciliation.test.ts": 9, + "src/renderer/src/components/activity/activity-scope-filter.test.ts": 9, + "src/renderer/src/components/activity/activity-terminal-portal-publication-loop.react185.test.tsx": 21, + "src/renderer/src/components/activity/activity-terminal-portal.test.tsx": 19, + "src/renderer/src/components/activity/activity-thread-actions.test.ts": 17, + "src/renderer/src/components/activity/activity-thread-child-agent.test.ts": 7, + "src/renderer/src/components/activity/activity-thread-grouping.search-cache.test.ts": 8, + "src/renderer/src/components/activity/activity-thread-grouping.status-order.test.ts": 17, + "src/renderer/src/components/activity/activity-thread-hover-card.test.tsx": 228, + "src/renderer/src/components/activity/activity-thread-list-pane-collapsible.test.tsx": 218, + "src/renderer/src/components/activity/activity-thread-list-pane.virtualization.test.tsx": 852, + "src/renderer/src/components/activity/activity-thread-presentation.test.ts": 9, + "src/renderer/src/components/activity/activity-thread-virtual-items.test.ts": 12, + "src/renderer/src/components/activity/dev-activity-fixture.test.ts": 7, + "src/renderer/src/components/activity/event-time-clock-refresh.test.tsx": 40, + "src/renderer/src/components/activity/use-activity-thread-action-bindings.test.tsx": 22, + "src/renderer/src/components/activity/useActivityUnreadCount.freshness.test.tsx": 42, + "src/renderer/src/components/activity/useActivityUnreadCount.test.ts": 7, + "src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.test.tsx": 42, + "src/renderer/src/components/agent-session-continuation/agent-session-continuation-selection.test.ts": 5, + "src/renderer/src/components/agent/AgentCombobox.test.tsx": 855, + "src/renderer/src/components/agent/AgentSettingsDialog.test.tsx": 68, + "src/renderer/src/components/agent/agent-combobox-command-state.test.ts": 6, + "src/renderer/src/components/artifacts/ArtifactCollection.test.tsx": 439, + "src/renderer/src/components/artifacts/ArtifactPreview.test.tsx": 69, + "src/renderer/src/components/artifacts/ArtifactPublishButton.test.tsx": 272, + "src/renderer/src/components/artifacts/ArtifactsPage.test.tsx": 1134, + "src/renderer/src/components/artifacts/artifact-display-labels.test.ts": 30, + "src/renderer/src/components/artifacts/artifact-list-search.test.ts": 11, + "src/renderer/src/components/artifacts/artifact-publish-flow.test.ts": 62, + "src/renderer/src/components/artifacts/artifact-published-link-client.test.ts": 13, + "src/renderer/src/components/automations/AutomationDestinationField.test.tsx": 285, + "src/renderer/src/components/automations/AutomationDetail.host.test.tsx": 104, + "src/renderer/src/components/automations/AutomationDetail.test.tsx": 81, + "src/renderer/src/components/automations/AutomationEditorPromptEditor.test.tsx": 35, + "src/renderer/src/components/automations/AutomationHostBadges.test.tsx": 191, + "src/renderer/src/components/automations/AutomationHostFilterNotice.test.tsx": 125, + "src/renderer/src/components/automations/AutomationListEmptyView.test.tsx": 53, + "src/renderer/src/components/automations/AutomationListHostGroups.test.tsx": 71, + "src/renderer/src/components/automations/AutomationListLocalRows.test.tsx": 298, + "src/renderer/src/components/automations/AutomationListSearchField.test.tsx": 74, + "src/renderer/src/components/automations/AutomationListTableHeader.test.tsx": 131, + "src/renderer/src/components/automations/AutomationOwnerConflictNotice.test.tsx": 60, + "src/renderer/src/components/automations/AutomationPromptDisclosure.test.tsx": 334, + "src/renderer/src/components/automations/AutomationRunHistory.test.tsx": 122, + "src/renderer/src/components/automations/AutomationRunsDashboardSurface.test.tsx": 23, + "src/renderer/src/components/automations/AutomationRunsTable.test.tsx": 60, + "src/renderer/src/components/automations/AutomationSchedulePicker.render.test.tsx": 915, + "src/renderer/src/components/automations/AutomationSchedulePicker.test.ts": 839, + "src/renderer/src/components/automations/AutomationTimeField.test.tsx": 248, + "src/renderer/src/components/automations/AutomationsDetailPane.run-count.test.tsx": 115, + "src/renderer/src/components/automations/AutomationsDetailPane.test.tsx": 178, + "src/renderer/src/components/automations/AutomationsListPanel.test.tsx": 271, + "src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx": 412, + "src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx": 137, + "src/renderer/src/components/automations/AutomationsPage.escape-precedence.test.tsx": 232, + "src/renderer/src/components/automations/AutomationsPage.external-scope.test.tsx": 212, + "src/renderer/src/components/automations/AutomationsPage.notice-recovery.test.tsx": 198, + "src/renderer/src/components/automations/AutomationsPage.refresh-selection.test.tsx": 376, + "src/renderer/src/components/automations/AutomationsPage.run-visibility.test.tsx": 133, + "src/renderer/src/components/automations/AutomationsPage.save-visibility.test.tsx": 195, + "src/renderer/src/components/automations/AutomationsPage.strict-mode-save-visibility.test.tsx": 233, + "src/renderer/src/components/automations/AutomationsPage.test.tsx": 644, + "src/renderer/src/components/automations/AutomationsPageBreadcrumb.test.tsx": 62, + "src/renderer/src/components/automations/CreateFromPicker.test.tsx": 75, + "src/renderer/src/components/automations/ExternalAutomationManagers.test.tsx": 247, + "src/renderer/src/components/automations/automation-authority-identity.test.ts": 8, + "src/renderer/src/components/automations/automation-captured-owner.test.ts": 8, + "src/renderer/src/components/automations/automation-create-destination.test.ts": 10, + "src/renderer/src/components/automations/automation-create-projects.test.ts": 6, + "src/renderer/src/components/automations/automation-detail-tab-navigation.test.ts": 16, + "src/renderer/src/components/automations/automation-editor-prompt-options.test.ts": 6, + "src/renderer/src/components/automations/automation-external-target-match.test.ts": 8, + "src/renderer/src/components/automations/automation-host-cache-controller.test.ts": 21, + "src/renderer/src/components/automations/automation-host-cache-health.test.ts": 9, + "src/renderer/src/components/automations/automation-host-cache.test.ts": 18, + "src/renderer/src/components/automations/automation-host-catalog-generation.test.ts": 14, + "src/renderer/src/components/automations/automation-host-catalog-order.test.ts": 19, + "src/renderer/src/components/automations/automation-host-catalog-source.test.ts": 11, + "src/renderer/src/components/automations/automation-host-catalog.test.ts": 64, + "src/renderer/src/components/automations/automation-host-client.test.ts": 7, + "src/renderer/src/components/automations/automation-host-detail-display.test.ts": 4, + "src/renderer/src/components/automations/automation-host-diagnostics.test.ts": 28, + "src/renderer/src/components/automations/automation-host-filter-resolution.test.ts": 28, + "src/renderer/src/components/automations/automation-host-health.test.ts": 7, + "src/renderer/src/components/automations/automation-host-invalidation-window-events.test.ts": 12, + "src/renderer/src/components/automations/automation-host-invalidation.test.ts": 17, + "src/renderer/src/components/automations/automation-host-list-rows.test.ts": 8, + "src/renderer/src/components/automations/automation-host-orphan-entry.test.ts": 22, + "src/renderer/src/components/automations/automation-host-picker-groups.test.ts": 19, + "src/renderer/src/components/automations/automation-host-recovery.test.ts": 5, + "src/renderer/src/components/automations/automation-host-scale-gates.test.ts": 195, + "src/renderer/src/components/automations/automation-host-scheduler.test.ts": 18, + "src/renderer/src/components/automations/automation-host-status-descriptors.test.ts": 24, + "src/renderer/src/components/automations/automation-list-empty-state.test.ts": 18, + "src/renderer/src/components/automations/automation-list-focus-recovery.test.ts": 7, + "src/renderer/src/components/automations/automation-list-keyboard-navigation.test.ts": 8, + "src/renderer/src/components/automations/automation-list-last-run.test.ts": 26, + "src/renderer/src/components/automations/automation-list-row-identity.test.ts": 7, + "src/renderer/src/components/automations/automation-list-search-rows.test.ts": 12, + "src/renderer/src/components/automations/automation-list-search.test.ts": 12, + "src/renderer/src/components/automations/automation-list-view-sort.test.ts": 175, + "src/renderer/src/components/automations/automation-list-view.test.ts": 15, + "src/renderer/src/components/automations/automation-project-groups.test.ts": 8, + "src/renderer/src/components/automations/automation-run-completion-evidence.test.ts": 7, + "src/renderer/src/components/automations/automation-run-context.test.ts": 5, + "src/renderer/src/components/automations/automation-run-history-keyboard-navigation.test.ts": 14, + "src/renderer/src/components/automations/automation-run-open-target.test.ts": 10, + "src/renderer/src/components/automations/automation-run-output-snapshot-equivalence.test.ts": 69, + "src/renderer/src/components/automations/automation-run-output-snapshot.test.ts": 47, + "src/renderer/src/components/automations/automation-run-view-state.test.ts": 11, + "src/renderer/src/components/automations/automation-runs-dashboard-model.test.ts": 6, + "src/renderer/src/components/automations/automation-scoped-list-client.test.ts": 76, + "src/renderer/src/components/automations/automation-setup-decision.test.ts": 8, + "src/renderer/src/components/automations/automation-source-display.test.ts": 10, + "src/renderer/src/components/automations/automation-target-availability.test.ts": 12, + "src/renderer/src/components/automations/automation-usage-model.test.ts": 6, + "src/renderer/src/components/automations/automation-write-invalidation.test.ts": 8, + "src/renderer/src/components/automations/external-automation-display.test.ts": 6, + "src/renderer/src/components/automations/external-automation-list-entries.test.ts": 7, + "src/renderer/src/components/automations/external-automation-run-table-state.test.ts": 9, + "src/renderer/src/components/automations/external-automation-scope-gating.test.ts": 11, + "src/renderer/src/components/automations/external-automation-scope-keys.test.ts": 7, + "src/renderer/src/components/automations/external-automation-source-availability.test.ts": 5, + "src/renderer/src/components/automations/hermes-cron-output-parse.test.ts": 27, + "src/renderer/src/components/automations/use-automation-host-catalog-readoption.test.tsx": 58, + "src/renderer/src/components/automations/use-automation-host-catalog.test.tsx": 56, + "src/renderer/src/components/automations/use-automation-list-focus-recovery.test.tsx": 26, + "src/renderer/src/components/automations/use-automation-list-search.test.tsx": 33, + "src/renderer/src/components/automations/use-automation-runs-dashboard.test.tsx": 53, + "src/renderer/src/components/automations/use-external-automation-scope-retention.test.tsx": 19, + "src/renderer/src/components/automations/use-selected-automation-run-history.test.tsx": 33, + "src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx": 99, + "src/renderer/src/components/browser-favicon.test.tsx": 55, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.chrome-parity.test.tsx": 391, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.dead-guest.test.tsx": 346, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.download-notices.test.tsx": 295, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.drag-focus.test.tsx": 551, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.failure-remount.test.tsx": 274, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.popup-notices.test.tsx": 111, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.test.tsx": 370, + "src/renderer/src/components/browser-pane/ReopenBrowserPageOnServerButton.test.tsx": 102, + "src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx": 10, + "src/renderer/src/components/browser-pane/annotate/GrabConfirmationSheet.test.ts": 7, + "src/renderer/src/components/browser-pane/annotate/browser-annotation-output.test.ts": 36, + "src/renderer/src/components/browser-pane/annotate/browser-page-annotation-tray.test.tsx": 326, + "src/renderer/src/components/browser-pane/annotate/markup-drawing-model.test.ts": 15, + "src/renderer/src/components/browser-pane/annotate/markup-screenshot-compose.test.ts": 8, + "src/renderer/src/components/browser-pane/annotate/markup-shape-render.test.ts": 7, + "src/renderer/src/components/browser-pane/annotate/use-markup-draw-hint.test.ts": 17, + "src/renderer/src/components/browser-pane/annotate/useGrabMode.test.ts": 369, + "src/renderer/src/components/browser-pane/annotate/useMarkupEditor.test.ts": 26, + "src/renderer/src/components/browser-pane/annotate/useMarkupPointerHandlers.test.ts": 18, + "src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.test.tsx": 79, + "src/renderer/src/components/browser-pane/assemble-chrome/BrowserFind.test.tsx": 152, + "src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.remote-link-routing.test.ts": 6, + "src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.render-ipc.test.ts": 6, + "src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.webview-preferences.test.ts": 20, + "src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.test.tsx": 951, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-expansion.test.ts": 7, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.test.ts": 23, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-deferred-lifecycle.test.tsx": 139, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-detected-browsers-summary.test.ts": 4, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-egress-indicator.test.tsx": 234, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-import-hint-visibility.test.ts": 4, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-navigation-control-row.test.tsx": 57, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-pane-page-selection.test.ts": 4, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.adoption-chrome.test.tsx": 1919, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.retention-props.test.tsx": 93, + "src/renderer/src/components/browser-pane/assemble-chrome/context-menu-positioning.test.ts": 10, + "src/renderer/src/components/browser-pane/assemble-chrome/ssh-routed-browser-page-gate.test.tsx": 175, + "src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.test.tsx": 118, + "src/renderer/src/components/browser-pane/browser-client-page-metadata-publisher.test.ts": 262, + "src/renderer/src/components/browser-pane/browser-client-page-metadata-route-census.test.ts": 6, + "src/renderer/src/components/browser-pane/browser-client-page-position-driver.test.ts": 47, + "src/renderer/src/components/browser-pane/browser-client-page-renderer-installation.test.ts": 12, + "src/renderer/src/components/browser-pane/browser-client-page-retained-drag-passthrough.test.ts": 57, + "src/renderer/src/components/browser-pane/browser-client-page-retained-registry.test.ts": 52, + "src/renderer/src/components/browser-pane/browser-download-destination-toast.test.ts": 7, + "src/renderer/src/components/browser-pane/browser-reopen-on-server.test.ts": 13, + "src/renderer/src/components/browser-pane/describe-page/browser-annotation-geometry.test.ts": 8, + "src/renderer/src/components/browser-pane/describe-page/browser-artifact-upload.test.ts": 11, + "src/renderer/src/components/browser-pane/describe-page/browser-favicon-url.test.ts": 7, + "src/renderer/src/components/browser-pane/describe-page/browser-overlay-shortcut-target.test.ts": 7, + "src/renderer/src/components/browser-pane/describe-page/browser-page-url-display.test.ts": 15, + "src/renderer/src/components/browser-pane/describe-page/live-browser-url-registry.test.ts": 6, + "src/renderer/src/components/browser-pane/host-guest/browser-automation-visibility.test.ts": 16, + "src/renderer/src/components/browser-pane/host-guest/browser-focus.test.ts": 9, + "src/renderer/src/components/browser-pane/host-guest/browser-guest-eviction-veto.test.ts": 10, + "src/renderer/src/components/browser-pane/host-guest/browser-guest-page-id-identity.test.ts": 6, + "src/renderer/src/components/browser-pane/host-guest/browser-guest-paint-retention.test.ts": 33, + "src/renderer/src/components/browser-pane/host-guest/browser-guest-retention-site-census.test.ts": 592, + "src/renderer/src/components/browser-pane/host-guest/browser-guest-worktree-retention.test.ts": 15, + "src/renderer/src/components/browser-pane/host-guest/browser-keyboard.test.ts": 20, + "src/renderer/src/components/browser-pane/host-guest/browser-page-evicted-guest-recovery.test.ts": 105, + "src/renderer/src/components/browser-pane/host-guest/browser-page-favicon-retention.test.ts": 12, + "src/renderer/src/components/browser-pane/host-guest/browser-page-guest-recovery.test.ts": 338, + "src/renderer/src/components/browser-pane/host-guest/browser-page-paintability.test.ts": 5, + "src/renderer/src/components/browser-pane/host-guest/browser-page-viewport.test.ts": 28, + "src/renderer/src/components/browser-pane/host-guest/browser-page-webview-surface.test.ts": 21, + "src/renderer/src/components/browser-pane/host-guest/browser-page-webview.test.ts": 5, + "src/renderer/src/components/browser-pane/host-guest/browser-page-zoom.test.ts": 16, + "src/renderer/src/components/browser-pane/host-guest/browser-system-resume.test.ts": 14, + "src/renderer/src/components/browser-pane/host-guest/browser-worktree-surface-paintability.test.ts": 6, + "src/renderer/src/components/browser-pane/host-guest/use-browser-page-slot-viewport.test.ts": 21, + "src/renderer/src/components/browser-pane/host-guest/use-browser-page-viewport-scroll-reporting.test.tsx": 35, + "src/renderer/src/components/browser-pane/host-guest/use-webview-drag-passthrough-active.test.tsx": 35, + "src/renderer/src/components/browser-pane/host-guest/webview-registry.test.ts": 44, + "src/renderer/src/components/browser-pane/navigate/browser-address-bar-navigation.test.ts": 12, + "src/renderer/src/components/browser-pane/navigate/browser-download-progress.test.ts": 7, + "src/renderer/src/components/browser-pane/navigate/browser-load-failure-overlay.test.tsx": 150, + "src/renderer/src/components/browser-pane/navigate/browser-notices.test.ts": 19, + "src/renderer/src/components/browser-pane/navigate/browser-page-download-activity.test.ts": 16, + "src/renderer/src/components/browser-pane/navigate/browser-reload-action.test.ts": 9, + "src/renderer/src/components/browser-pane/navigate/browser-webview-load-failure.test.ts": 11, + "src/renderer/src/components/browser-pane/navigate/chromium-error-page-poll-visibility.test.ts": 40, + "src/renderer/src/components/browser-pane/navigate/chromium-error-page-polling.test.ts": 5, + "src/renderer/src/components/browser-pane/navigate/use-browser-page-reload-actions.test.tsx": 19, + "src/renderer/src/components/browser-pane/restored-client-hosted-recovery-window.test.tsx": 308, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-frame-style.test.ts": 7, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-keyboard.test.ts": 4, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-input-model.test.ts": 8, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.address-bar.test.tsx": 293, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.chrome-chords.test.tsx": 434, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-errors.test.ts": 10, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.test.ts": 61, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-scheduler.test.ts": 12, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-status.test.ts": 6, + "src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-navigation.test.ts": 37, + "src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-stream-activation.test.ts": 54, + "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx": 2857, + "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx": 4032, + "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts": 28, + "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-identity.test.ts": 8, + "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-external-link-confirmation.test.ts": 67, + "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-webview-attach.test.ts": 14, + "src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.lazy-ref.test.tsx": 29, + "src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts": 29, + "src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx": 41, + "src/renderer/src/components/browser-profile-user-agent-option.test.tsx": 82, + "src/renderer/src/components/browser-webauthn-account-dialog.test.tsx": 166, + "src/renderer/src/components/cmd-j/palette-activation-focus-routing.test.ts": 7, + "src/renderer/src/components/cmd-j/palette-duplicate-key-ghost-rows.test.tsx": 26, + "src/renderer/src/components/cmd-j/palette-filter-option-list.test.ts": 15, + "src/renderer/src/components/cmd-j/palette-filter-options.test.ts": 23, + "src/renderer/src/components/cmd-j/palette-filter.test.ts": 12, + "src/renderer/src/components/cmd-j/palette-focus-restore-target.test.ts": 11, + "src/renderer/src/components/cmd-j/palette-host-badge.test.ts": 15, + "src/renderer/src/components/cmd-j/palette-list-entry-render-keys.test.ts": 6, + "src/renderer/src/components/cmd-j/palette-live-status.test.tsx": 183, + "src/renderer/src/components/cmd-j/palette-query-tokens.test.ts": 9, + "src/renderer/src/components/cmd-j/palette-results.test.ts": 42, + "src/renderer/src/components/cmd-j/palette-section-render-cap.test.ts": 12, + "src/renderer/src/components/cmd-j/palette-session-age.test.ts": 16, + "src/renderer/src/components/cmd-j/plugin-quick-actions.test.ts": 9, + "src/renderer/src/components/cmd-j/quick-action-context.test.ts": 20, + "src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts": 27, + "src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.test.ts": 5, + "src/renderer/src/components/codex-restart-chip.test.tsx": 120, + "src/renderer/src/components/codex-restart-notice-key.test.ts": 4, + "src/renderer/src/components/comment-code-context-state.test.ts": 5, + "src/renderer/src/components/comment-reply-target-state.test.ts": 6, + "src/renderer/src/components/confirmation-dialog-refresh-boundary.test.ts": 44, + "src/renderer/src/components/confirmation-dialog.test.tsx": 445, + "src/renderer/src/components/confirmation-skip-preference.test.ts": 109, + "src/renderer/src/components/contextual-tours/ContextualTourControl.test.ts": 8, + "src/renderer/src/components/contextual-tours/ContextualTourOverlay.remeasure.test.tsx": 313, + "src/renderer/src/components/contextual-tours/ContextualTourOverlay.test.tsx": 136, + "src/renderer/src/components/contextual-tours/ContextualTourOverlay.visibility.test.tsx": 2421, + "src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.localization.test.tsx": 191, + "src/renderer/src/components/contextual-tours/contextual-tour-floating-position.test.ts": 1879, + "src/renderer/src/components/contextual-tours/contextual-tour-gate.test.ts": 14, + "src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.test.ts": 91, + "src/renderer/src/components/contextual-tours/contextual-tour-step-actions.test.ts": 6, + "src/renderer/src/components/contextual-tours/request-contextual-tour-when-ready.test.ts": 15, + "src/renderer/src/components/contextual-tours/use-contextual-tour.test.ts": 8, + "src/renderer/src/components/contextual-tours/workspace-creation-tour-handoff.test.ts": 9, + "src/renderer/src/components/crash-report/CrashReportDialogSurface.overflow.test.tsx": 109, + "src/renderer/src/components/crash-report/crash-report-submit-notice.test.ts": 13, + "src/renderer/src/components/crash-report/use-crash-report-copy.test.tsx": 21, + "src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx": 469, + "src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx": 393, + "src/renderer/src/components/dashboard-popout/AgentMap.test.tsx": 1741, + "src/renderer/src/components/dashboard-popout/AgentMapCanvas.performance.test.tsx": 75, + "src/renderer/src/components/dashboard-popout/AgentMapFilterPanel.test.tsx": 126, + "src/renderer/src/components/dashboard-popout/AgentMapMotion.test.tsx": 189, + "src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx": 106, + "src/renderer/src/components/dashboard-popout/AgentMapRingHover.test.tsx": 289, + "src/renderer/src/components/dashboard-popout/AgentMapStatusGlow.test.tsx": 732, + "src/renderer/src/components/dashboard-popout/AgentMapTimeRangeField.test.tsx": 1678, + "src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.boundary.test.ts": 7, + "src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.test.tsx": 2066, + "src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx": 234, + "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx": 1314, + "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.option-dead-key.test.tsx": 149, + "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx": 1273, + "src/renderer/src/components/dashboard-popout/DashboardHostBadge.test.tsx": 64, + "src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.test.tsx": 26, + "src/renderer/src/components/dashboard-popout/agent-board-filtering.test.ts": 8, + "src/renderer/src/components/dashboard-popout/agent-map-filter.test.ts": 9, + "src/renderer/src/components/dashboard-popout/agent-map-glow.performance.test.ts": 12, + "src/renderer/src/components/dashboard-popout/agent-map-hover-containment.test.ts": 7, + "src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts": 12, + "src/renderer/src/components/dashboard-popout/agent-map-layout.test.ts": 152, + "src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.test.ts": 26, + "src/renderer/src/components/dashboard-popout/agent-map-navigation.test.ts": 9, + "src/renderer/src/components/dashboard-popout/agent-map-node-metadata.test.ts": 10, + "src/renderer/src/components/dashboard-popout/agent-map-node-presentation.test.ts": 4, + "src/renderer/src/components/dashboard-popout/agent-map-quick-views.test.ts": 11, + "src/renderer/src/components/dashboard-popout/agent-map-time-filter.test.ts": 6, + "src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.test.ts": 5, + "src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.test.ts": 8, + "src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.test.ts": 161, + "src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.test.ts": 1600, + "src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts": 9, + "src/renderer/src/components/dashboard-popout/preview-grid-claim.test.ts": 18, + "src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts": 228, + "src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts": 11, + "src/renderer/src/components/dashboard-popout/preview-terminal-right-click-paste.test.ts": 63, + "src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts": 13, + "src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts": 11, + "src/renderer/src/components/dashboard-popout/terminal-preview-unavailable-message.test.ts": 10, + "src/renderer/src/components/dashboard-popout/useAgentMapFilters.test.tsx": 36, + "src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx": 59, + "src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx": 227, + "src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.test.tsx": 68, + "src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx": 97, + "src/renderer/src/components/dashboard/agent-dashboard-performance-isolation.test.ts": 6, + "src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts": 5, + "src/renderer/src/components/dashboard/agent-row-lineage-model.test.ts": 6, + "src/renderer/src/components/dashboard/agent-row-pane-live-title.test.ts": 7, + "src/renderer/src/components/dashboard/build-dashboard-bucket-counts.allocation.test.ts": 97, + "src/renderer/src/components/dashboard/build-dashboard-bucket-counts.cache.test.ts": 11, + "src/renderer/src/components/dashboard/build-dashboard-bucket-counts.equivalence.test.ts": 40, + "src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts": 21, + "src/renderer/src/components/dashboard/build-dashboard-snapshot-orchestration-routing.test.ts": 15, + "src/renderer/src/components/dashboard/build-dashboard-snapshot.rows-cache.test.ts": 30, + "src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts": 34, + "src/renderer/src/components/dashboard/dashboard-card-context.test.ts": 14, + "src/renderer/src/components/dashboard/dashboard-card-labels.test.ts": 5, + "src/renderer/src/components/dashboard/dashboard-card-terminal-input.test.ts": 11, + "src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts": 19, + "src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts": 7, + "src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts": 10, + "src/renderer/src/components/dashboard/useAgentBucketCounts.gate.test.ts": 46, + "src/renderer/src/components/dashboard/useAgentBucketCounts.test.tsx": 35, + "src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx": 57, + "src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts": 63, + "src/renderer/src/components/dashboard/useRetainedAgents.test.ts": 66, + "src/renderer/src/components/dashboard/useRetainedAgentsSync.test.ts": 6, + "src/renderer/src/components/dictation/DictationIndicator.localization.test.ts": 7, + "src/renderer/src/components/dictation/DictationIndicator.test.tsx": 86, + "src/renderer/src/components/dictation/dictation-audio-meter.test.ts": 5, + "src/renderer/src/components/dictation/dictation-final-segments.test.ts": 5, + "src/renderer/src/components/dictation/dictation-insertion-target.test.ts": 288, + "src/renderer/src/components/dictation/dictation-meter-store.test.tsx": 15, + "src/renderer/src/components/dictation/dictation-stopped-sessions.test.ts": 9, + "src/renderer/src/components/dictation/microphone-devices.test.ts": 16, + "src/renderer/src/components/dictation/use-hold-dictation-gesture.test.tsx": 43, + "src/renderer/src/components/diff-comments/DiffCommentCard.resize.test.tsx": 127, + "src/renderer/src/components/diff-comments/diff-comment-popover-outside-click.test.tsx": 59, + "src/renderer/src/components/diff-comments/diff-comment-popover-position.test.ts": 8, + "src/renderer/src/components/diff-comments/diff-comment-zone-mouse-events.test.ts": 5, + "src/renderer/src/components/diff-comments/useDiffCommentDecorator.commentable-lines.test.tsx": 43, + "src/renderer/src/components/diff-comments/useDiffCommentDecorator.model-lifecycle.test.tsx": 28, + "src/renderer/src/components/editor/ChangesModeView.test.tsx": 27, + "src/renderer/src/components/editor/CheckRunCopyButton.test.tsx": 70, + "src/renderer/src/components/editor/CheckRunDetailsPanel.copy.test.tsx": 116, + "src/renderer/src/components/editor/ConflictComponents.test.tsx": 4, + "src/renderer/src/components/editor/EditorContent.markdown-classification.test.tsx": 60, + "src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx": 49, + "src/renderer/src/components/editor/EditorContent.test.tsx": 37, + "src/renderer/src/components/editor/EditorPanel.markdown-classification-memoization.test.tsx": 462, + "src/renderer/src/components/editor/EditorPanelHeader.test.tsx": 34, + "src/renderer/src/components/editor/EditorPanelMarkdownActionsMenu.test.tsx": 21, + "src/renderer/src/components/editor/EditorPanelShell.header.test.tsx": 18, + "src/renderer/src/components/editor/ExternalFileChangeBanner.test.tsx": 56, + "src/renderer/src/components/editor/ExternalFileChangeCompareDialog.test.tsx": 186, + "src/renderer/src/components/editor/ImageViewer.test.tsx": 105, + "src/renderer/src/components/editor/LargeDiffLoadPrompt.test.tsx": 42, + "src/renderer/src/components/editor/MarkdownPreview.link-routing.interaction.test.tsx": 106, + "src/renderer/src/components/editor/MarkdownPreview.test.ts": 9, + "src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx": 107, + "src/renderer/src/components/editor/MarkdownTableOfContentsPanel.test.tsx": 23, + "src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx": 56, + "src/renderer/src/components/editor/MonacoEditor.font-family.test.tsx": 46, + "src/renderer/src/components/editor/NotesSendMenu.test.tsx": 34, + "src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx": 30, + "src/renderer/src/components/editor/RichMarkdownErrorBoundary.lazy-chunk.test.tsx": 104, + "src/renderer/src/components/editor/RichMarkdownLinkBubble.render.test.tsx": 106, + "src/renderer/src/components/editor/RichMarkdownLinkBubble.test.ts": 3, + "src/renderer/src/components/editor/RichMarkdownSearchBar.ime-enter.test.tsx": 187, + "src/renderer/src/components/editor/RichMarkdownSlashMenu.test.tsx": 14, + "src/renderer/src/components/editor/RichMarkdownTableControls.test.tsx": 272, + "src/renderer/src/components/editor/check-annotation-open.test.ts": 9, + "src/renderer/src/components/editor/check-annotation-path.test.ts": 10, + "src/renderer/src/components/editor/check-job-step-status.test.ts": 5, + "src/renderer/src/components/editor/check-run-clipboard-text.test.ts": 8, + "src/renderer/src/components/editor/check-run-details-fix-with-ai.test.ts": 16, + "src/renderer/src/components/editor/check-run-details-tab.test.ts": 7, + "src/renderer/src/components/editor/closed-editor-tab-cache-sweep.test.ts": 10, + "src/renderer/src/components/editor/closed-editor-tab-disposal.test.ts": 48, + "src/renderer/src/components/editor/combined-diff-on-demand-load.test.ts": 9, + "src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-collapsed-work.test.tsx": 96, + "src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-render.test.tsx": 241, + "src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-windowing.test.tsx": 212, + "src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.test.ts": 13, + "src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-file-tree-resize.test.tsx": 44, + "src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-tree-navigation.test.ts": 17, + "src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-initial-section-load.test.ts": 5, + "src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-load-scheduler.test.ts": 7, + "src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-connection.test.ts": 11, + "src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-state.test.ts": 10, + "src/renderer/src/components/editor/combined-diff/remember-view/use-combined-diff-view-restore.test.tsx": 29, + "src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-entries.test.ts": 12, + "src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-cache-match.test.ts": 5, + "src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-scaling.test.tsx": 157, + "src/renderer/src/components/editor/combined-diff/resolve-changes/use-combined-diff-section-index-map.test.tsx": 23, + "src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-commit-message.test.ts": 17, + "src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-restore-signal-equivalence.test.tsx": 291, + "src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-scrollbar-drag.test.ts": 8, + "src/renderer/src/components/editor/csv-parse.test.ts": 38, + "src/renderer/src/components/editor/details-markdown-html.test.ts": 24, + "src/renderer/src/components/editor/diff-editor-line-number-options.test.ts": 8, + "src/renderer/src/components/editor/diff-editor-shift-wheel-scroll.test.ts": 14, + "src/renderer/src/components/editor/diff-editor-whitespace-options.test.ts": 6, + "src/renderer/src/components/editor/diff-editor-word-wrap-options.test.ts": 4, + "src/renderer/src/components/editor/diff-line-stats.test.ts": 10, + "src/renderer/src/components/editor/diff-model-swap-view-state.test.ts": 8, + "src/renderer/src/components/editor/diff-monaco-model-disposal.test.ts": 12, + "src/renderer/src/components/editor/diff-navigation-context.test.tsx": 45, + "src/renderer/src/components/editor/diff-section-layout.test.ts": 30, + "src/renderer/src/components/editor/diff-section-preview.test.ts": 9, + "src/renderer/src/components/editor/diff-viewer-large-diff-save-action.test.ts": 7, + "src/renderer/src/components/editor/editor-autosave-conflict-flow.test.ts": 37, + "src/renderer/src/components/editor/editor-autosave-controller.test.ts": 40, + "src/renderer/src/components/editor/editor-autosave.test.ts": 17, + "src/renderer/src/components/editor/editor-cmd-save-target.test.ts": 10, + "src/renderer/src/components/editor/editor-content-dirty-state.test.ts": 21, + "src/renderer/src/components/editor/editor-external-watch-path-index.test.ts": 9, + "src/renderer/src/components/editor/editor-file-save-attempt.test.ts": 11, + "src/renderer/src/components/editor/editor-header.test.ts": 8, + "src/renderer/src/components/editor/editor-labels.test.ts": 6, + "src/renderer/src/components/editor/editor-panel-diff-reload.test.ts": 8, + "src/renderer/src/components/editor/editor-panel-draft-selector.test.ts": 20, + "src/renderer/src/components/editor/editor-panel-git-entry-selector.test.ts": 24, + "src/renderer/src/components/editor/editor-panel-render-model.test.ts": 29, + "src/renderer/src/components/editor/editor-path-move-inflight.test.ts": 9, + "src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts": 38, + "src/renderer/src/components/editor/editor-self-write-registry.test.ts": 20, + "src/renderer/src/components/editor/editor-shortcuts.test.ts": 23, + "src/renderer/src/components/editor/export-active-markdown.test.ts": 7, + "src/renderer/src/components/editor/file-editor-word-wrap-options.test.ts": 4, + "src/renderer/src/components/editor/image-viewer-zoom.test.ts": 10, + "src/renderer/src/components/editor/ipynb-code-cell-lines.test.ts": 138, + "src/renderer/src/components/editor/ipynb-parse.test.ts": 26, + "src/renderer/src/components/editor/large-diff-render-limit.test.ts": 103, + "src/renderer/src/components/editor/line-copy-path.test.ts": 5, + "src/renderer/src/components/editor/local-log-tail-decoder.test.ts": 8, + "src/renderer/src/components/editor/markdown-artifact-upload.test.ts": 7, + "src/renderer/src/components/editor/markdown-dirty-state.test.ts": 154, + "src/renderer/src/components/editor/markdown-doc-completions.test.ts": 11, + "src/renderer/src/components/editor/markdown-doc-links.test.ts": 29, + "src/renderer/src/components/editor/markdown-document-list-request.test.ts": 14, + "src/renderer/src/components/editor/markdown-document-worktree-path-selector.test.ts": 758, + "src/renderer/src/components/editor/markdown-export-extract.test.ts": 21, + "src/renderer/src/components/editor/markdown-export-html.test.ts": 5, + "src/renderer/src/components/editor/markdown-frontmatter.test.ts": 7, + "src/renderer/src/components/editor/markdown-heading-slug.test.ts": 6, + "src/renderer/src/components/editor/markdown-internal-links.test.ts": 10, + "src/renderer/src/components/editor/markdown-preview-annotation-shortcut.test.ts": 25, + "src/renderer/src/components/editor/markdown-preview-controls.test.ts": 10, + "src/renderer/src/components/editor/markdown-preview-links.test.ts": 8, + "src/renderer/src/components/editor/markdown-preview-local-images.test.ts": 67, + "src/renderer/src/components/editor/markdown-preview-search-crash.test.tsx": 24, + "src/renderer/src/components/editor/markdown-preview-search.test.ts": 16, + "src/renderer/src/components/editor/markdown-preview-url-transform.test.ts": 14, + "src/renderer/src/components/editor/markdown-reference-link-normalization.test.ts": 25, + "src/renderer/src/components/editor/markdown-render-mode.test.ts": 5, + "src/renderer/src/components/editor/markdown-rich-mode-eligibility-cache.test.ts": 225, + "src/renderer/src/components/editor/markdown-rich-mode.test.ts": 276, + "src/renderer/src/components/editor/markdown-rich-size-limit.test.ts": 7, + "src/renderer/src/components/editor/markdown-round-trip.test.ts": 388, + "src/renderer/src/components/editor/markdown-table-of-contents.test.ts": 57, + "src/renderer/src/components/editor/markdown-toc-collapse-state.test.ts": 5, + "src/renderer/src/components/editor/markdown-toc-visibility-gate.test.ts": 18, + "src/renderer/src/components/editor/mermaid-config.test.ts": 6, + "src/renderer/src/components/editor/monaco-auto-height.test.ts": 42, + "src/renderer/src/components/editor/monaco-codebase-search.test.ts": 8, + "src/renderer/src/components/editor/monaco-conflict-decorations.test.ts": 12, + "src/renderer/src/components/editor/monaco-content-sync.test.ts": 14, + "src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts": 44, + "src/renderer/src/components/editor/monaco-context-menu-paste.test.ts": 59, + "src/renderer/src/components/editor/monaco-find-options.test.ts": 5, + "src/renderer/src/components/editor/monaco-find-widget.test.ts": 12, + "src/renderer/src/components/editor/monaco-large-text-paste.test.ts": 19, + "src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.offset-scan.test.ts": 72, + "src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.test.ts": 15, + "src/renderer/src/components/editor/monaco-markdown-selection-annotation.test.ts": 9, + "src/renderer/src/components/editor/monaco-programmatic-sync.test.ts": 5, + "src/renderer/src/components/editor/monaco-reveal-range.test.ts": 4, + "src/renderer/src/components/editor/monaco-view-state-persistence.test.ts": 5, + "src/renderer/src/components/editor/pdf-scale-preference.test.ts": 8, + "src/renderer/src/components/editor/pdf-view-position.test.ts": 19, + "src/renderer/src/components/editor/pending-editor-focus-request.test.ts": 6, + "src/renderer/src/components/editor/position-stable-node-view-update.test.ts": 7, + "src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts": 321, + "src/renderer/src/components/editor/rich-markdown-auto-focus.test.ts": 12, + "src/renderer/src/components/editor/rich-markdown-code-block-languages.test.ts": 8, + "src/renderer/src/components/editor/rich-markdown-commands.test.ts": 95, + "src/renderer/src/components/editor/rich-markdown-context-command-routing.test.ts": 56, + "src/renderer/src/components/editor/rich-markdown-cut.test.ts": 142, + "src/renderer/src/components/editor/rich-markdown-details-keyboard.test.ts": 83, + "src/renderer/src/components/editor/rich-markdown-doc-link.test.ts": 7, + "src/renderer/src/components/editor/rich-markdown-editor-click-routing.test.ts": 13, + "src/renderer/src/components/editor/rich-markdown-editor-config.test.ts": 15, + "src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.test.ts": 57, + "src/renderer/src/components/editor/rich-markdown-html-superscript-link.test.ts": 581, + "src/renderer/src/components/editor/rich-markdown-image-insert-code-block.test.ts": 270, + "src/renderer/src/components/editor/rich-markdown-image-insert.test.ts": 54, + "src/renderer/src/components/editor/rich-markdown-inline-image-paragraph.test.ts": 90, + "src/renderer/src/components/editor/rich-markdown-key-handler.test.ts": 83, + "src/renderer/src/components/editor/rich-markdown-large-text-paste.test.ts": 17, + "src/renderer/src/components/editor/rich-markdown-link-clipboard.test.ts": 7, + "src/renderer/src/components/editor/rich-markdown-link-shortcut.test.ts": 96, + "src/renderer/src/components/editor/rich-markdown-list-continuation.test.ts": 35, + "src/renderer/src/components/editor/rich-markdown-list-tokenizers.test.ts": 11, + "src/renderer/src/components/editor/rich-markdown-local-image.test.ts": 128, + "src/renderer/src/components/editor/rich-markdown-lowlight-cache.test.ts": 80, + "src/renderer/src/components/editor/rich-markdown-lowlight.test.ts": 2575, + "src/renderer/src/components/editor/rich-markdown-normalize.test.ts": 43, + "src/renderer/src/components/editor/rich-markdown-paragraph.test.ts": 9, + "src/renderer/src/components/editor/rich-markdown-paste-handler.test.ts": 8, + "src/renderer/src/components/editor/rich-markdown-paste-image.test.ts": 11, + "src/renderer/src/components/editor/rich-markdown-range-bounds.test.ts": 66, + "src/renderer/src/components/editor/rich-markdown-review-annotations.test.ts": 15, + "src/renderer/src/components/editor/rich-markdown-review-note-layout.test.ts": 6, + "src/renderer/src/components/editor/rich-markdown-review-rail-blocks.test.ts": 203, + "src/renderer/src/components/editor/rich-markdown-review-text-ranges.test.ts": 26, + "src/renderer/src/components/editor/rich-markdown-search-matches-cache.test.ts": 38, + "src/renderer/src/components/editor/rich-markdown-search.test.ts": 10, + "src/renderer/src/components/editor/rich-markdown-serialization-commit.test.ts": 21, + "src/renderer/src/components/editor/rich-markdown-slash-command-filter.test.ts": 7, + "src/renderer/src/components/editor/rich-markdown-source-reconcile.test.ts": 332, + "src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts": 130, + "src/renderer/src/components/editor/rich-markdown-table-actions.test.ts": 475, + "src/renderer/src/components/editor/rich-markdown-table-control-layout.test.ts": 4, + "src/renderer/src/components/editor/rich-markdown-table-keyboard.test.ts": 105, + "src/renderer/src/components/editor/rich-markdown-terminal-path-paste.test.ts": 11, + "src/renderer/src/components/editor/rich-markdown-toc-heading-target.test.ts": 11, + "src/renderer/src/components/editor/selection-copy.test.ts": 8, + "src/renderer/src/components/editor/setup-contextual-copy.test.ts": 10, + "src/renderer/src/components/editor/untitled-file-rename-path.test.ts": 6, + "src/renderer/src/components/editor/use-monaco-editor-decorations.doc-link-refresh.test.tsx": 42, + "src/renderer/src/components/editor/use-rich-markdown-table-context-menu.test.tsx": 86, + "src/renderer/src/components/editor/use-rich-markdown-table-control-target.test.tsx": 83, + "src/renderer/src/components/editor/useDiffViewerLargeDiffLifecycle.test.tsx": 16, + "src/renderer/src/components/editor/useEditorCmdSaveRequest.test.tsx": 34, + "src/renderer/src/components/editor/useEditorPanelContentState.test.tsx": 823, + "src/renderer/src/components/editor/useEditorPanelExternalContentEvents.test.tsx": 32, + "src/renderer/src/components/editor/useEditorPanelFileLoadRetry.test.tsx": 53, + "src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx": 25, + "src/renderer/src/components/editor/useEditorPanelVisibilityContentState.test.tsx": 151, + "src/renderer/src/components/editor/useIpynbCellExecution.test.tsx": 78, + "src/renderer/src/components/editor/useIpynbDocumentEditing.test.tsx": 29, + "src/renderer/src/components/editor/useLinkBubble.test.tsx": 37, + "src/renderer/src/components/editor/useLocalImageSrc.test.ts": 74, + "src/renderer/src/components/editor/useLocalLogTail.test.tsx": 234, + "src/renderer/src/components/editor/useMarkdownDocuments.test.ts": 9, + "src/renderer/src/components/editor/useRichMarkdownEditorInstance.integration.test.tsx": 71, + "src/renderer/src/components/editor/useRichMarkdownEditorInstance.test.tsx": 23, + "src/renderer/src/components/editor/useRichMarkdownPendingFocus.test.ts": 20, + "src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.test.ts": 77, + "src/renderer/src/components/editor/useRichMarkdownReviewController.open-guard.test.ts": 17, + "src/renderer/src/components/editor/useRichMarkdownSearch.reuse.test.tsx": 55, + "src/renderer/src/components/editor/useRichMarkdownSearch.test.tsx": 31, + "src/renderer/src/components/emulator-pane/MobileEmulatorTabIntroCallout.test.tsx": 60, + "src/renderer/src/components/emulator-pane/emulator-device-frame-layout.test.ts": 10, + "src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx": 158, + "src/renderer/src/components/emulator-pane/emulator-device-frame.input.test.tsx": 188, + "src/renderer/src/components/emulator-pane/emulator-device-state.test.ts": 5, + "src/renderer/src/components/emulator-pane/emulator-keyboard-paste.test.ts": 16, + "src/renderer/src/components/emulator-pane/emulator-screen-gesture.test.ts": 11, + "src/renderer/src/components/emulator-pane/emulator-screen-stream-content.test.tsx": 43, + "src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-cli-state.test.ts": 6, + "src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-visibility.test.ts": 4, + "src/renderer/src/components/emulator-pane/mobile-emulator-hidden-toast.test.tsx": 10, + "src/renderer/src/components/emulator-pane/mobile-emulator-tab-intro-visibility.test.ts": 7, + "src/renderer/src/components/emulator-pane/use-emulator-pane-session.test.tsx": 53, + "src/renderer/src/components/emulator-pane/use-mobile-emulator-tab-intro-actions.test.tsx": 43, + "src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.lazy-chunk.test.tsx": 86, + "src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.never-landed-reload.test.tsx": 51, + "src/renderer/src/components/error-boundaries/react-185-bystander-attribution.test.tsx": 100, + "src/renderer/src/components/feature-interaction-writer-boundaries.test.ts": 16, + "src/renderer/src/components/feature-tips/CliSkillSetupTerminal.test.tsx": 33, + "src/renderer/src/components/feature-tips/CmdJPaletteFeatureTipVisual.test.tsx": 71, + "src/renderer/src/components/feature-tips/CmdJPaletteTipDialog.test.tsx": 35, + "src/renderer/src/components/feature-tips/VoiceDictationFeatureTipVisual.test.tsx": 72, + "src/renderer/src/components/feature-tips/VoiceDictationTipDialog.test.tsx": 44, + "src/renderer/src/components/feature-tips/feature-tip-cli-install-action.test.ts": 9, + "src/renderer/src/components/feature-tips/feature-tip-modal-state.test.ts": 10, + "src/renderer/src/components/feature-tips/feature-tip-startup-gate.test.ts": 12, + "src/renderer/src/components/feature-tips/feature-tip-telemetry.test.ts": 8, + "src/renderer/src/components/feature-wall/AgentCapabilitiesSetupAction.test.ts": 6, + "src/renderer/src/components/feature-wall/ConnectIntegrationsList.test.tsx": 181, + "src/renderer/src/components/feature-wall/FeatureWallSetupWorkflowActions.test.tsx": 74, + "src/renderer/src/components/feature-wall/FullDiskAccessSetupPrompt.test.ts": 113, + "src/renderer/src/components/feature-wall/KeepAwakeCard.test.tsx": 99, + "src/renderer/src/components/feature-wall/browser-animated-visual-sequence.test.ts": 6, + "src/renderer/src/components/feature-wall/feature-wall-animation-visibility.test.tsx": 163, + "src/renderer/src/components/feature-wall/feature-wall-rail-navigation.test.ts": 9, + "src/renderer/src/components/feature-wall/feature-wall-setup-checklist-localized-copy.test.ts": 9, + "src/renderer/src/components/feature-wall/feature-wall-setup-progress.test.ts": 12, + "src/renderer/src/components/feature-wall/feature-wall-shortcut-labels.test.tsx": 72, + "src/renderer/src/components/feature-wall/feature-wall-usage-tracking.test.ts": 13, + "src/renderer/src/components/feature-wall/use-feature-wall-completion.test.ts": 8, + "src/renderer/src/components/feature-wall/use-feature-wall-tour-telemetry.test.ts": 8, + "src/renderer/src/components/feature-wall/use-integration-connection-status.test.ts": 15, + "src/renderer/src/components/feature-wall/workbench-terminal-storyboard-sequence.test.ts": 9, + "src/renderer/src/components/file-path-cursor-tooltip.test.ts": 5, + "src/renderer/src/components/floating-terminal/FloatingBrowserSlot.test.tsx": 35, + "src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.freshness.test.tsx": 163, + "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.bounds.test.tsx": 365, + "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.empty-state.test.tsx": 526, + "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.focus.test.tsx": 615, + "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.markdown-editor.test.tsx": 782, + "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.shortcuts.test.tsx": 878, + "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.test.tsx": 693, + "src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.test.tsx": 84, + "src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx": 10, + "src/renderer/src/components/floating-terminal/floating-terminal-panel-bounds.test.ts": 17, + "src/renderer/src/components/floating-terminal/floating-terminal-panel-inputs.test.ts": 6, + "src/renderer/src/components/floating-terminal/floating-terminal-panel-view-state.test.ts": 9, + "src/renderer/src/components/floating-terminal/floating-terminal-trigger-position.test.ts": 17, + "src/renderer/src/components/floating-terminal/floating-workspace-tab-reorder.test.ts": 73, + "src/renderer/src/components/floating-terminal/terminal-pane-handle-registry.test.ts": 11, + "src/renderer/src/components/github-body-draft-state.test.ts": 5, + "src/renderer/src/components/github-checks-tab-state.test.ts": 11, + "src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts": 6, + "src/renderer/src/components/github-item-dialog-source-boundary.test.ts": 13, + "src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-section-index.test.tsx": 49, + "src/renderer/src/components/github-link-copy-state.test.ts": 6, + "src/renderer/src/components/github-pr-merge-state.test.ts": 25, + "src/renderer/src/components/github-pr-reviewer-display.test.ts": 11, + "src/renderer/src/components/github-project/GhAuthErrorHelp.test.ts": 57, + "src/renderer/src/components/github-project/ProjectRoadmap.test.tsx": 257, + "src/renderer/src/components/github-project/github-project-picker-filter.test.ts": 10, + "src/renderer/src/components/github-project/group-sort.test.ts": 13, + "src/renderer/src/components/github-project/project-dialog-state.test.ts": 10, + "src/renderer/src/components/github-project/project-picker-browse-cache.test.ts": 14, + "src/renderer/src/components/github-project/project-picker-input.test.ts": 10, + "src/renderer/src/components/github-project/project-row-filtering.test.ts": 14, + "src/renderer/src/components/github-project/project-view-wrapper-source-context-boundary.test.ts": 11, + "src/renderer/src/components/github-project/project-visible-table-cache.test.ts": 6, + "src/renderer/src/components/github/PRFilterPickers.test.ts": 6, + "src/renderer/src/components/github/github-markdown-image-url.test.ts": 5, + "src/renderer/src/components/github/github-mention-option-filter.test.ts": 10, + "src/renderer/src/components/github/github-pr-reviewer-candidate-filter.test.ts": 18, + "src/renderer/src/components/github/github-user-avatar.test.tsx": 66, + "src/renderer/src/components/github/github-work-item-assignee-filter.test.ts": 10, + "src/renderer/src/components/github/github-work-item-label-filter.test.ts": 7, + "src/renderer/src/components/github/pr-comment-code-context.test.ts": 29, + "src/renderer/src/components/github/pr-file-content-size.test.ts": 5, + "src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts": 13, + "src/renderer/src/components/github/use-image-input.test.ts": 186, + "src/renderer/src/components/hover-reveal-touch-action-visibility.test.ts": 12, + "src/renderer/src/components/jira-create-adf.test.ts": 10, + "src/renderer/src/components/jira-project-picker-filter.test.ts": 11, + "src/renderer/src/components/landing-preflight-dismissal.test.ts": 12, + "src/renderer/src/components/landing-preflight-issues.test.ts": 14, + "src/renderer/src/components/landing-preflight-runtime-boundary.test.ts": 59, + "src/renderer/src/components/linear-api-key-dialog-state.test.ts": 6, + "src/renderer/src/components/linear-issue-attribute-filter-coverage-agreement.test.ts": 1157, + "src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx": 1107, + "src/renderer/src/components/linear-issue-attribute-filter-primary-team.test.ts": 21, + "src/renderer/src/components/linear-issue-attribute-filter-team-ids.test.ts": 22, + "src/renderer/src/components/linear-issue-project-selector.test.tsx": 88, + "src/renderer/src/components/linear-issue-text-draft-state.test.ts": 4, + "src/renderer/src/components/linear-issue-text-save-plan.test.ts": 6, + "src/renderer/src/components/linear-issue-view-storage.test.ts": 11, + "src/renderer/src/components/linear-issue-workspace-api-parity.test.ts": 9, + "src/renderer/src/components/linear-issue-workspace-detail-state.test.tsx": 47, + "src/renderer/src/components/linear-issue-workspace-header.test.ts": 5, + "src/renderer/src/components/linear-issue-workspace-text.test.ts": 7, + "src/renderer/src/components/linear-project-presentation.test.ts": 9, + "src/renderer/src/components/linear-project-search-query.test.ts": 5, + "src/renderer/src/components/linear-project-view-surfaces.test.tsx": 104, + "src/renderer/src/components/linear-scope-selector.test.ts": 8, + "src/renderer/src/components/link-actions/LinkActionPopover.test.tsx": 167, + "src/renderer/src/components/maintenance/update-card/update-card-error-model.test.ts": 13, + "src/renderer/src/components/mobile/MobileHero.test.tsx": 637, + "src/renderer/src/components/mobile/MobilePage.test.tsx": 685, + "src/renderer/src/components/mobile/MobilePageToolbar.test.tsx": 41, + "src/renderer/src/components/mobile/NetworkInterfacePicker.test.tsx": 77, + "src/renderer/src/components/mobile/WindowsFirewallNotice.test.tsx": 173, + "src/renderer/src/components/mobile/mobile-page-stage.test.ts": 4, + "src/renderer/src/components/mobile/paired-mobile-devices.test.ts": 5, + "src/renderer/src/components/mobile/use-mobile-page-paired-devices.test.ts": 85, + "src/renderer/src/components/mobile/use-mobile-pairing-address-preference.test.tsx": 35, + "src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx": 69, + "src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx": 142, + "src/renderer/src/components/native-chat/NativeChatComposer.test.tsx": 164, + "src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx": 64, + "src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx": 72, + "src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx": 207, + "src/renderer/src/components/native-chat/NativeChatMessageList.provider-frame.test.tsx": 20, + "src/renderer/src/components/native-chat/NativeChatMessageList.stream-render.perf.test.tsx": 893, + "src/renderer/src/components/native-chat/NativeChatMessageList.task-list-frames.test.tsx": 515, + "src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx": 376, + "src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx": 2900, + "src/renderer/src/components/native-chat/NativeChatMessageList.turn-history.test.tsx": 355, + "src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx": 374, + "src/renderer/src/components/native-chat/NativeChatMessageList.turn-timing.test.tsx": 126, + "src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx": 3124, + "src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx": 180, + "src/renderer/src/components/native-chat/NativeChatMessageTimestamp.test.tsx": 359, + "src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx": 192, + "src/renderer/src/components/native-chat/NativeChatPromptEditor.test.tsx": 137, + "src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx": 153, + "src/renderer/src/components/native-chat/NativeChatResolutionReceipt.test.tsx": 268, + "src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx": 155, + "src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx": 509, + "src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx": 15241, + "src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx": 15300, + "src/renderer/src/components/native-chat/NativeChatSubagentRun.test.tsx": 187, + "src/renderer/src/components/native-chat/NativeChatTaskList.test.tsx": 175, + "src/renderer/src/components/native-chat/NativeChatToolRun.identity.test.tsx": 253, + "src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx": 438, + "src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx": 107, + "src/renderer/src/components/native-chat/NativeChatView.test.tsx": 58, + "src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.test.tsx": 65, + "src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx": 49, + "src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx": 410, + "src/renderer/src/components/native-chat/background-task-roster.test.ts": 21, + "src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts": 14, + "src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts": 13, + "src/renderer/src/components/native-chat/native-chat-assembler-merge-parity.test.ts": 8, + "src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts": 12, + "src/renderer/src/components/native-chat/native-chat-autoscroll.test.ts": 7, + "src/renderer/src/components/native-chat/native-chat-availability.test.ts": 9, + "src/renderer/src/components/native-chat/native-chat-composer-autogrow.test.tsx": 178, + "src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx": 469, + "src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx": 153, + "src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx": 71, + "src/renderer/src/components/native-chat/native-chat-composer-scope-cache.test.ts": 5, + "src/renderer/src/components/native-chat/native-chat-composer-state.test.ts": 25, + "src/renderer/src/components/native-chat/native-chat-diff.test.ts": 13, + "src/renderer/src/components/native-chat/native-chat-dismiss-key.test.ts": 9, + "src/renderer/src/components/native-chat/native-chat-draft-cache.test.ts": 6, + "src/renderer/src/components/native-chat/native-chat-file-link.test.ts": 10, + "src/renderer/src/components/native-chat/native-chat-font-scale.test.ts": 11, + "src/renderer/src/components/native-chat/native-chat-http-link-source-owner.test.ts": 13, + "src/renderer/src/components/native-chat/native-chat-image-paste.test.ts": 9, + "src/renderer/src/components/native-chat/native-chat-image-runtime-context.test.ts": 8, + "src/renderer/src/components/native-chat/native-chat-incremental-assembler.test.ts": 9, + "src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts": 17, + "src/renderer/src/components/native-chat/native-chat-launch-default-adoption.test.ts": 14, + "src/renderer/src/components/native-chat/native-chat-launch-draft-resolution.test.ts": 9, + "src/renderer/src/components/native-chat/native-chat-launch-draft-send.test.ts": 9, + "src/renderer/src/components/native-chat/native-chat-launch-session-options.test.ts": 5, + "src/renderer/src/components/native-chat/native-chat-layout-actions.test.ts": 6, + "src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts": 6, + "src/renderer/src/components/native-chat/native-chat-live-status.test.ts": 13, + "src/renderer/src/components/native-chat/native-chat-message-grouping.test.ts": 8, + "src/renderer/src/components/native-chat/native-chat-message-list-projection.test.ts": 11, + "src/renderer/src/components/native-chat/native-chat-noise.test.ts": 8, + "src/renderer/src/components/native-chat/native-chat-pagination.test.ts": 5, + "src/renderer/src/components/native-chat/native-chat-pane-resolution.test.ts": 15, + "src/renderer/src/components/native-chat/native-chat-pending-occurrence.test.ts": 9, + "src/renderer/src/components/native-chat/native-chat-pending.test.ts": 36, + "src/renderer/src/components/native-chat/native-chat-pinned-rows.test.ts": 6, + "src/renderer/src/components/native-chat/native-chat-preassembled-session-parity.test.ts": 267, + "src/renderer/src/components/native-chat/native-chat-pty-retired-model.test.ts": 12, + "src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts": 235, + "src/renderer/src/components/native-chat/native-chat-retire-persisted-model.test.ts": 88, + "src/renderer/src/components/native-chat/native-chat-row-height-estimate.test.ts": 9, + "src/renderer/src/components/native-chat/native-chat-runtime-owner.test.ts": 7, + "src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts": 1210, + "src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts": 187, + "src/renderer/src/components/native-chat/native-chat-scrape-fallback.test.ts": 11, + "src/renderer/src/components/native-chat/native-chat-send-eligibility.test.ts": 9, + "src/renderer/src/components/native-chat/native-chat-send.test.ts": 11, + "src/renderer/src/components/native-chat/native-chat-session-assembler.test.ts": 9, + "src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts": 325, + "src/renderer/src/components/native-chat/native-chat-session-option-labels.test.ts": 6, + "src/renderer/src/components/native-chat/native-chat-session-transport.test.ts": 49, + "src/renderer/src/components/native-chat/native-chat-shared-copy-matches-catalog.test.ts": 5, + "src/renderer/src/components/native-chat/native-chat-shortcut.test.ts": 7, + "src/renderer/src/components/native-chat/native-chat-split-shortcut.test.ts": 7, + "src/renderer/src/components/native-chat/native-chat-stop-layering.test.ts": 4, + "src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx": 271, + "src/renderer/src/components/native-chat/native-chat-tab-agent-entry.test.ts": 7, + "src/renderer/src/components/native-chat/native-chat-task-list-history.test.ts": 10, + "src/renderer/src/components/native-chat/native-chat-task-list-state.test.ts": 13, + "src/renderer/src/components/native-chat/native-chat-tool-fold.test.ts": 12, + "src/renderer/src/components/native-chat/native-chat-tool-summary.test.ts": 11, + "src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts": 6, + "src/renderer/src/components/native-chat/native-chat-turn-diffs.test.ts": 25, + "src/renderer/src/components/native-chat/native-chat-typing-indicator.test.ts": 11, + "src/renderer/src/components/native-chat/native-chat-typing-redirect.test.ts": 5, + "src/renderer/src/components/native-chat/native-chat-view-state.test.ts": 9, + "src/renderer/src/components/native-chat/native-chat-web-link-actions.test.ts": 18, + "src/renderer/src/components/native-chat/native-chat-working-status-shared-clock.test.tsx": 55, + "src/renderer/src/components/native-chat/native-chat-working-suppression.test.ts": 8, + "src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts": 8, + "src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts": 24, + "src/renderer/src/components/native-chat/structured-session-takeover-report.test.tsx": 74, + "src/renderer/src/components/native-chat/use-native-chat-composer-app-menu-selection.test.tsx": 65, + "src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx": 83, + "src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx": 45, + "src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx": 30, + "src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx": 27, + "src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx": 45, + "src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx": 46, + "src/renderer/src/components/native-chat/use-native-chat-hook-status.test.ts": 4, + "src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx": 34, + "src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.test.tsx": 51, + "src/renderer/src/components/native-chat/use-native-chat-link-actions.test.tsx": 320, + "src/renderer/src/components/native-chat/use-native-chat-live-session-pending.test.ts": 63, + "src/renderer/src/components/native-chat/use-native-chat-live-session-visibility.test.ts": 75, + "src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts": 158, + "src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.test.tsx": 26, + "src/renderer/src/components/native-chat/use-native-chat-retained-session.test.ts": 32, + "src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx": 17, + "src/renderer/src/components/native-chat/use-native-chat-session-option-command.test.tsx": 30, + "src/renderer/src/components/native-chat/use-native-chat-session-options.test.ts": 248, + "src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx": 396, + "src/renderer/src/components/native-chat/use-native-chat-skills.test.ts": 8, + "src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx": 193, + "src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.test.ts": 10, + "src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx": 88, + "src/renderer/src/components/native-chat/use-structured-agent-session-hold.test.tsx": 199, + "src/renderer/src/components/native-chat/use-structured-agent-session-messages.test.tsx": 24, + "src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx": 870, + "src/renderer/src/components/native-chat/use-structured-agent-session-read.test.tsx": 596, + "src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx": 605, + "src/renderer/src/components/native-chat/use-structured-agent-turn-timing.test.tsx": 23, + "src/renderer/src/components/network/CustomAddressDialog.test.tsx": 72, + "src/renderer/src/components/new-workspace/ComposerParentWorktreePicker.test.tsx": 175, + "src/renderer/src/components/new-workspace/ProjectCombobox.dialog-handoff.test.tsx": 499, + "src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx": 245, + "src/renderer/src/components/new-workspace/ProjectComboboxRow.test.tsx": 65, + "src/renderer/src/components/new-workspace/SetProjectLocationDialog.test.tsx": 999, + "src/renderer/src/components/new-workspace/SmartWorkspaceNameField-provider-boundaries.test.ts": 7, + "src/renderer/src/components/new-workspace/SmartWorkspaceNameField-repo-slug-routing.test.ts": 14, + "src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts": 5, + "src/renderer/src/components/new-workspace/SmartWorkspaceNameField.ime-enter.test.tsx": 253, + "src/renderer/src/components/new-workspace/SmartWorkspaceNameField.jira-accessibility.test.tsx": 500, + "src/renderer/src/components/new-workspace/project-combobox-matching.test.ts": 29, + "src/renderer/src/components/new-workspace/smart-workspace-command-value.test.ts": 7, + "src/renderer/src/components/new-workspace/smart-workspace-localized-options.test.ts": 59, + "src/renderer/src/components/new-workspace/smart-workspace-source-popover-focus.test.ts": 17, + "src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts": 16, + "src/renderer/src/components/new-workspace/use-jira-source-connection.test.tsx": 46, + "src/renderer/src/components/new-workspace/use-jira-url-source.test.tsx": 39, + "src/renderer/src/components/new-workspace/use-recent-project-ids.test.ts": 6, + "src/renderer/src/components/onboarding/AgentStep.test.tsx": 78, + "src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.test.tsx": 31, + "src/renderer/src/components/onboarding/NotificationStep.test.tsx": 61, + "src/renderer/src/components/onboarding/OnboardingFlow.test.tsx": 128, + "src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.command-finished.test.tsx": 47, + "src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.test.ts": 4, + "src/renderer/src/components/onboarding/ThemeStep.test.ts": 8, + "src/renderer/src/components/onboarding/WindowsTerminalStep.test.tsx": 51, + "src/renderer/src/components/onboarding/agent-picked-payload.test.ts": 10, + "src/renderer/src/components/onboarding/onboarding-dismiss-target.test.ts": 4, + "src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts": 23, + "src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts": 10, + "src/renderer/src/components/onboarding/onboarding-settings-hydration.test.ts": 5, + "src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts": 34, + "src/renderer/src/components/onboarding/use-onboarding-flow.test.ts": 21, + "src/renderer/src/components/onboarding/windows-terminal-onboarding-telemetry.test.ts": 5, + "src/renderer/src/components/orca-profiles/OrcaProfileSignOutConfirmDialog.test.tsx": 21, + "src/renderer/src/components/pet/PetOverlay.frame-durations.test.tsx": 50, + "src/renderer/src/components/pet/PetOverlay.keyframes.test.tsx": 217, + "src/renderer/src/components/pet/PetOverlay.pet-switch.test.tsx": 37, + "src/renderer/src/components/pet/PetOverlay.pointer-interaction.test.tsx": 75, + "src/renderer/src/components/pet/PetOverlay.test.ts": 5, + "src/renderer/src/components/pet/pet-agent-state.test.ts": 14, + "src/renderer/src/components/pet/pet-blob-cache.test.ts": 12, + "src/renderer/src/components/pet/pet-overlay-hit-area.test.tsx": 49, + "src/renderer/src/components/pet/pet-overlay-position.test.ts": 6, + "src/renderer/src/components/pet/pet-overlay-visibility.test.ts": 6, + "src/renderer/src/components/pet/sprite-animation-css.test.ts": 9, + "src/renderer/src/components/pet/usePetPointerInteraction.test.ts": 23, + "src/renderer/src/components/pet/usePetUrl.test.tsx": 15, + "src/renderer/src/components/ports/WorkspacePortScanner.test.tsx": 215, + "src/renderer/src/components/pr-check-counts.test.ts": 11, + "src/renderer/src/components/pr-checks-fix-prompt.test.ts": 20, + "src/renderer/src/components/pr-comments-resolution-prompt.test.ts": 9, + "src/renderer/src/components/provider-check-classification-parity.test.ts": 14, + "src/renderer/src/components/pull-request-page-host-boundary.test.ts": 17, + "src/renderer/src/components/pull-request-page/cache/file-content.test.ts": 11, + "src/renderer/src/components/pull-request-page/files/combined-diff-section-index.test.tsx": 22, + "src/renderer/src/components/pull-request-page/mentions/options.test.ts": 4, + "src/renderer/src/components/pull-request-page/mentions/query.test.ts": 7, + "src/renderer/src/components/pull-request-page/presentation/state-badge.test.ts": 6, + "src/renderer/src/components/quick-open-file-list.react.test.tsx": 90, + "src/renderer/src/components/quick-open-file-list.test.ts": 9, + "src/renderer/src/components/quick-open-install-rg-guidance.render.test.tsx": 57, + "src/renderer/src/components/quick-open-install-rg-guidance.test.ts": 9, + "src/renderer/src/components/quick-open-search.test.ts": 248, + "src/renderer/src/components/repo/NestedRepoChecklist.test.tsx": 17, + "src/renderer/src/components/repo/NestedRepoScanLimitNotice.test.ts": 5, + "src/renderer/src/components/repo/repo-icon.emoji-centering.test.tsx": 45, + "src/renderer/src/components/repo/repo-icon.test.tsx": 61, + "src/renderer/src/components/right-sidebar/ActionButton.test.tsx": 17, + "src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx": 294, + "src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.test.tsx": 67, + "src/renderer/src/components/right-sidebar/ChecksPanel.review-header.test.tsx": 28, + "src/renderer/src/components/right-sidebar/ChecksPanel.updated-at-metadata.test.tsx": 46, + "src/renderer/src/components/right-sidebar/CommitArea.chevron-spinner.test.tsx": 78, + "src/renderer/src/components/right-sidebar/CommitArea.generate.test.tsx": 87, + "src/renderer/src/components/right-sidebar/CommitArea.primary-icons.test.tsx": 92, + "src/renderer/src/components/right-sidebar/CommitArea.test.tsx": 373, + "src/renderer/src/components/right-sidebar/FileExplorerNameFilter.test.tsx": 12, + "src/renderer/src/components/right-sidebar/FileExplorerRow.actions.test.tsx": 19, + "src/renderer/src/components/right-sidebar/FileExplorerToolbar.test.tsx": 45, + "src/renderer/src/components/right-sidebar/FileExplorerViewSwitch.test.tsx": 10, + "src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.row-handlers.test.tsx": 16, + "src/renderer/src/components/right-sidebar/FileExplorerVirtualRowsAddProject.test.tsx": 14, + "src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx": 300, + "src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.test.tsx": 72, + "src/renderer/src/components/right-sidebar/FolderWorkspaceWorktreesPanel.test.tsx": 69, + "src/renderer/src/components/right-sidebar/GitHistoryPanel.test.tsx": 60, + "src/renderer/src/components/right-sidebar/GitHubPRStackMap.test.tsx": 75, + "src/renderer/src/components/right-sidebar/HostedReviewActions.draft.test.tsx": 22, + "src/renderer/src/components/right-sidebar/PluginPanel.test.tsx": 220, + "src/renderer/src/components/right-sidebar/PortsPanel.test.tsx": 1028, + "src/renderer/src/components/right-sidebar/PullRequestComposer.generate-tooltip.test.tsx": 753, + "src/renderer/src/components/right-sidebar/SearchResultItems.test.tsx": 18, + "src/renderer/src/components/right-sidebar/SessionRowTrailingActions.test.tsx": 54, + "src/renderer/src/components/right-sidebar/SourceControl.branch-line-total.test.tsx": 774, + "src/renderer/src/components/right-sidebar/SourceControl.commit-drafts.test.ts": 18, + "src/renderer/src/components/right-sidebar/SourceControl.commit-failure-recovery.test.ts": 9, + "src/renderer/src/components/right-sidebar/SourceControl.commit-generation-records.test.ts": 8, + "src/renderer/src/components/right-sidebar/SourceControl.compare-summary.test.ts": 14, + "src/renderer/src/components/right-sidebar/SourceControl.host-context-boundary.test.ts": 8, + "src/renderer/src/components/right-sidebar/SourceControl.hosted-review-header-link.test.tsx": 19, + "src/renderer/src/components/right-sidebar/SourceControl.open-file-highlight.test.tsx": 546, + "src/renderer/src/components/right-sidebar/SourceControl.pr-generation-records.test.ts": 15, + "src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx": 833, + "src/renderer/src/components/right-sidebar/SourceControl.push-failure-recovery.test.ts": 8, + "src/renderer/src/components/right-sidebar/SourceControl.remote-action-errors.test.ts": 9, + "src/renderer/src/components/right-sidebar/SourceControl.resolve-conflicts-prompt.test.ts": 8, + "src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx": 2498, + "src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx": 425, + "src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx": 74, + "src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialog.test.ts": 94, + "src/renderer/src/components/right-sidebar/active-checks-status.test.ts": 9, + "src/renderer/src/components/right-sidebar/activity-bar-buttons.test.tsx": 59, + "src/renderer/src/components/right-sidebar/activity-bar-overflow.test.ts": 7, + "src/renderer/src/components/right-sidebar/ai-vault-first-prompt-card.test.tsx": 137, + "src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts": 32, + "src/renderer/src/components/right-sidebar/ai-vault-original-pane-index.test.ts": 16, + "src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts": 7, + "src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.test.ts": 8, + "src/renderer/src/components/right-sidebar/ai-vault-scope-paths.test.ts": 12, + "src/renderer/src/components/right-sidebar/ai-vault-scope-state.test.ts": 8, + "src/renderer/src/components/right-sidebar/ai-vault-session-continuation.test.ts": 7, + "src/renderer/src/components/right-sidebar/ai-vault-session-deletability.test.ts": 10, + "src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts": 13, + "src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts": 19, + "src/renderer/src/components/right-sidebar/ai-vault-session-identity.test.ts": 9, + "src/renderer/src/components/right-sidebar/ai-vault-session-log-open.test.ts": 15, + "src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.test.ts": 7, + "src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts": 22, + "src/renderer/src/components/right-sidebar/ai-vault-session-publication-gate.test.ts": 17, + "src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts": 193, + "src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts": 9, + "src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.test.ts": 10, + "src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts": 19, + "src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx": 61, + "src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts": 19, + "src/renderer/src/components/right-sidebar/ai-vault-view-defaults.test.ts": 10, + "src/renderer/src/components/right-sidebar/ai-vault-view-options-persistence.test.ts": 13, + "src/renderer/src/components/right-sidebar/branch-line-total-request-gate.test.ts": 6, + "src/renderer/src/components/right-sidebar/check-details-resize.test.ts": 4, + "src/renderer/src/components/right-sidebar/checks-entry-refresh.test.ts": 10, + "src/renderer/src/components/right-sidebar/checks-list-expanded-details.test.tsx": 327, + "src/renderer/src/components/right-sidebar/checks-panel-async-result-key.test.ts": 4, + "src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx": 100, + "src/renderer/src/components/right-sidebar/checks-panel-empty-state.test.ts": 28, + "src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.test.ts": 14, + "src/renderer/src/components/right-sidebar/checks-panel-hosted-review-click-routing.test.ts": 10, + "src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-breadcrumb.test.ts": 11, + "src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-request.test.ts": 9, + "src/renderer/src/components/right-sidebar/checks-panel-review-creation.test.ts": 16, + "src/renderer/src/components/right-sidebar/checks-panel-review-lookup-authority.test.ts": 11, + "src/renderer/src/components/right-sidebar/checks-panel-review.test.ts": 8, + "src/renderer/src/components/right-sidebar/checks-panel-terminal-worktree.test.ts": 12, + "src/renderer/src/components/right-sidebar/checks-panel/gitlab-review-client.test.ts": 9, + "src/renderer/src/components/right-sidebar/checks-panel/panel-content-rendering.test.tsx": 90, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.test.tsx": 33, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-create-review.test.tsx": 22, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-generation.test.tsx": 17, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-git-status-effects.test.tsx": 37, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.test.tsx": 15, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.test.tsx": 38, + "src/renderer/src/components/right-sidebar/coalesced-poll-runner.test.ts": 18, + "src/renderer/src/components/right-sidebar/commit-failure-dialog-state.test.ts": 4, + "src/renderer/src/components/right-sidebar/commit-failure-summary.test.ts": 21, + "src/renderer/src/components/right-sidebar/create-review-draft-title.test.ts": 7, + "src/renderer/src/components/right-sidebar/diff-comments-clear-dialog-state.test.ts": 6, + "src/renderer/src/components/right-sidebar/discard-all-sequence.test.ts": 19, + "src/renderer/src/components/right-sidebar/file-explorer-add-project-action.test.ts": 7, + "src/renderer/src/components/right-sidebar/file-explorer-batch-deletion.test.ts": 140, + "src/renderer/src/components/right-sidebar/file-explorer-deferred-dir-toggle.test.ts": 42, + "src/renderer/src/components/right-sidebar/file-explorer-dir-load-tracker.test.ts": 4, + "src/renderer/src/components/right-sidebar/file-explorer-dir-toggle-timing.test.ts": 11, + "src/renderer/src/components/right-sidebar/file-explorer-directory-listing.test.ts": 5, + "src/renderer/src/components/right-sidebar/file-explorer-drag-scroll-marker.test.tsx": 56, + "src/renderer/src/components/right-sidebar/file-explorer-entries.test.ts": 9, + "src/renderer/src/components/right-sidebar/file-explorer-expanded-dirs-refresh.test.ts": 23, + "src/renderer/src/components/right-sidebar/file-explorer-inline-input-outside-click.test.tsx": 62, + "src/renderer/src/components/right-sidebar/file-explorer-inline-rename-flow.test.tsx": 113, + "src/renderer/src/components/right-sidebar/file-explorer-keyboard-navigation.test.ts": 11, + "src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.test.ts": 5, + "src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts": 35, + "src/renderer/src/components/right-sidebar/file-explorer-paths.test.ts": 5, + "src/renderer/src/components/right-sidebar/file-explorer-refresh-concurrency.test.ts": 4, + "src/renderer/src/components/right-sidebar/file-explorer-reset.test.ts": 5, + "src/renderer/src/components/right-sidebar/file-explorer-row-projection.test.ts": 8, + "src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts": 9, + "src/renderer/src/components/right-sidebar/file-explorer-selection.test.ts": 8, + "src/renderer/src/components/right-sidebar/file-explorer-stale-dir-cache.test.ts": 5, + "src/renderer/src/components/right-sidebar/file-explorer-watch-drive-root.test.ts": 12, + "src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.test.ts": 193, + "src/renderer/src/components/right-sidebar/file-explorer-watch-refresh-scheduler.test.ts": 37, + "src/renderer/src/components/right-sidebar/file-search-include-pattern.test.ts": 8, + "src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.test.ts": 16, + "src/renderer/src/components/right-sidebar/fork-push-target-label.test.ts": 6, + "src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts": 32, + "src/renderer/src/components/right-sidebar/git-status-refresh-branch-line-total.test.ts": 19, + "src/renderer/src/components/right-sidebar/git-status-refresh-scheduler.test.ts": 23, + "src/renderer/src/components/right-sidebar/git-status-refresh.test.ts": 66, + "src/renderer/src/components/right-sidebar/github-pr-link-modal.test.ts": 9, + "src/renderer/src/components/right-sidebar/github-pr-stack-merge.test.ts": 12, + "src/renderer/src/components/right-sidebar/gitlab-mr-merge-state.test.ts": 10, + "src/renderer/src/components/right-sidebar/local-workspace-port-sections.test.ts": 8, + "src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.test.ts": 77, + "src/renderer/src/components/right-sidebar/parent-pr-checks-refresh.test.ts": 32, + "src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts": 37, + "src/renderer/src/components/right-sidebar/plugin-panel-activity-items.test.ts": 5, + "src/renderer/src/components/right-sidebar/plugin-panel-bridge-host.test.ts": 45, + "src/renderer/src/components/right-sidebar/plugin-panel-watchdog-visibility.test.ts": 12, + "src/renderer/src/components/right-sidebar/plugin-panel-watchdog.test.ts": 12, + "src/renderer/src/components/right-sidebar/pr-comment-presentation.test.ts": 9, + "src/renderer/src/components/right-sidebar/pr-comment-snapshotted-thread-resolver.test.ts": 11, + "src/renderer/src/components/right-sidebar/pr-comment-thread-resolution.test.ts": 4, + "src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.test.ts": 23, + "src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx": 791, + "src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.test.ts": 11, + "src/renderer/src/components/right-sidebar/review-cache-entry-selection.test.ts": 11, + "src/renderer/src/components/right-sidebar/right-panel-comment-composer.ime-enter.test.tsx": 103, + "src/renderer/src/components/right-sidebar/right-panel-comment-focus-timers.test.ts": 8, + "src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.test.ts": 5, + "src/renderer/src/components/right-sidebar/right-sidebar-effective-tab.test.ts": 5, + "src/renderer/src/components/right-sidebar/right-sidebar-primary-action-layout.test.ts": 5, + "src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.render.test.tsx": 126, + "src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.test.ts": 5, + "src/renderer/src/components/right-sidebar/right-sidebar-width.test.ts": 5, + "src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts": 11, + "src/renderer/src/components/right-sidebar/search-match-open.test.ts": 12, + "src/renderer/src/components/right-sidebar/search-rows.test.ts": 6, + "src/renderer/src/components/right-sidebar/source-control-action-recipe-match.test.ts": 11, + "src/renderer/src/components/right-sidebar/source-control-actions.test.ts": 5, + "src/renderer/src/components/right-sidebar/source-control-active-open-file-keys.test.ts": 8, + "src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-support.test.ts": 4, + "src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx": 147, + "src/renderer/src/components/right-sidebar/source-control-branch-context-stats.test.ts": 17, + "src/renderer/src/components/right-sidebar/source-control-branch-section-heading.test.tsx": 23, + "src/renderer/src/components/right-sidebar/source-control-commit-eligibility.test.ts": 7, + "src/renderer/src/components/right-sidebar/source-control-commit-message-rows.test.ts": 99, + "src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts": 18, + "src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.test.ts": 7, + "src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.test.ts": 10, + "src/renderer/src/components/right-sidebar/source-control-created-review-link.test.ts": 6, + "src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts": 17, + "src/renderer/src/components/right-sidebar/source-control-discard-dialog.test.tsx": 168, + "src/renderer/src/components/right-sidebar/source-control-dropdown-items.create-pr-intent.test.ts": 24, + "src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts": 34, + "src/renderer/src/components/right-sidebar/source-control-entry-actions.test.ts": 5, + "src/renderer/src/components/right-sidebar/source-control-entry-context-menu.test.tsx": 17, + "src/renderer/src/components/right-sidebar/source-control-file-filter.test.ts": 6, + "src/renderer/src/components/right-sidebar/source-control-header-toolbar-identity.test.tsx": 100, + "src/renderer/src/components/right-sidebar/source-control-header-toolbar.test.ts": 6, + "src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.test.ts": 17, + "src/renderer/src/components/right-sidebar/source-control-hosted-review-push-target.test.ts": 6, + "src/renderer/src/components/right-sidebar/source-control-huge-repo-warning-dismissals.test.ts": 237, + "src/renderer/src/components/right-sidebar/source-control-manual-review-url.test.ts": 13, + "src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts": 29, + "src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts": 34, + "src/renderer/src/components/right-sidebar/source-control-push-recovery.test.ts": 8, + "src/renderer/src/components/right-sidebar/source-control-section-order.test.ts": 9, + "src/renderer/src/components/right-sidebar/source-control-split-open.test.ts": 7, + "src/renderer/src/components/right-sidebar/source-control-status-sort.test.ts": 8, + "src/renderer/src/components/right-sidebar/source-control-submodule-expansion.test.ts": 16, + "src/renderer/src/components/right-sidebar/source-control-text-generation-defaults.test.ts": 8, + "src/renderer/src/components/right-sidebar/source-control-too-many-changes-banner.test.tsx": 87, + "src/renderer/src/components/right-sidebar/source-control-tree.test.ts": 13, + "src/renderer/src/components/right-sidebar/source-control-virtual-file-list.test.tsx": 148, + "src/renderer/src/components/right-sidebar/source-control/listing/use-file-projection-work.test.tsx": 31, + "src/renderer/src/components/right-sidebar/source-control/listing/use-store-actions.store-subscriptions.test.tsx": 50, + "src/renderer/src/components/right-sidebar/source-control/review/suppressed-github-pr.test.ts": 10, + "src/renderer/src/components/right-sidebar/source-control/review/use-action-model.test.tsx": 20, + "src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-state.test.tsx": 38, + "src/renderer/src/components/right-sidebar/status-display.test.ts": 4, + "src/renderer/src/components/right-sidebar/use-checks-panel-terminal-worktree.test.ts": 87, + "src/renderer/src/components/right-sidebar/use-git-status-upstream-ref-watch.test.ts": 11, + "src/renderer/src/components/right-sidebar/use-hosted-review-actions.test.tsx": 47, + "src/renderer/src/components/right-sidebar/use-installed-plugin-route-reconciliation.test.tsx": 23, + "src/renderer/src/components/right-sidebar/use-persisted-ai-vault-view-options.test.tsx": 34, + "src/renderer/src/components/right-sidebar/use-plugin-panel-theme-revision.test.tsx": 47, + "src/renderer/src/components/right-sidebar/use-source-control-ai.test.ts": 6, + "src/renderer/src/components/right-sidebar/use-source-control-branch-compare.test.tsx": 64, + "src/renderer/src/components/right-sidebar/use-source-control-git-history.test.tsx": 45, + "src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.repo-default.test.ts": 28, + "src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts": 48, + "src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.test.ts": 5, + "src/renderer/src/components/right-sidebar/useFileExplorerHandlers.test.ts": 14, + "src/renderer/src/components/right-sidebar/useFileExplorerKeys.test.ts": 4, + "src/renderer/src/components/right-sidebar/useFileExplorerRowDrag.test.ts": 20, + "src/renderer/src/components/right-sidebar/useFileExplorerTree.refresh-projection-churn.test.tsx": 35, + "src/renderer/src/components/right-sidebar/useFileExplorerTree.stale-dirs.test.tsx": 37, + "src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.debounce.test.tsx": 110, + "src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.test.ts": 12, + "src/renderer/src/components/right-sidebar/useFileExplorerWatch.pending-refresh.test.tsx": 27, + "src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts": 12, + "src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx": 27, + "src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts": 57, + "src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts": 585, + "src/renderer/src/components/right-sidebar/useHostedReviewStackParent.test.tsx": 33, + "src/renderer/src/components/right-sidebar/useSourceControlSelection.test.ts": 9, + "src/renderer/src/components/right-sidebar/useSourceControlSubmoduleStatus.test.tsx": 39, + "src/renderer/src/components/settings/AccountsPane.test.tsx": 508, + "src/renderer/src/components/settings/AdvancedNetworkSettingsSection.test.ts": 9, + "src/renderer/src/components/settings/AdvancedPane.test.tsx": 37, + "src/renderer/src/components/settings/AgentSkillSetupPanel.freshness.test.tsx": 63, + "src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx": 479, + "src/renderer/src/components/settings/AgentsPane.test.tsx": 445, + "src/renderer/src/components/settings/AppearancePane.test.tsx": 694, + "src/renderer/src/components/settings/ArtifactsSettingsPane.test.tsx": 358, + "src/renderer/src/components/settings/AutomationsSettingsPane.test.tsx": 117, + "src/renderer/src/components/settings/BranchPrefixFeedback.test.tsx": 16, + "src/renderer/src/components/settings/BrowserClientHostedRemoteSetting.test.tsx": 116, + "src/renderer/src/components/settings/BrowserPane.test.ts": 6, + "src/renderer/src/components/settings/BrowserSshWorkspaceRoutingSetting.test.tsx": 111, + "src/renderer/src/components/settings/BrowserUseSkillStep.test.tsx": 11, + "src/renderer/src/components/settings/CliSection.install-failure.test.tsx": 197, + "src/renderer/src/components/settings/CliSection.test.tsx": 87, + "src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx": 60, + "src/renderer/src/components/settings/CloudVmSetupGuide.test.tsx": 28, + "src/renderer/src/components/settings/CommitMessageAiPane.test.tsx": 532, + "src/renderer/src/components/settings/DefaultWindowsProjectRuntimeSetting.test.tsx": 28, + "src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx": 283, + "src/renderer/src/components/settings/DiffShowWhitespaceSetting.test.tsx": 57, + "src/renderer/src/components/settings/EditorWordWrapSetting.test.tsx": 74, + "src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx": 128, + "src/renderer/src/components/settings/EphemeralVmsPane.test.tsx": 103, + "src/renderer/src/components/settings/ExperimentalPane.test.tsx": 797, + "src/renderer/src/components/settings/FloatingWorkspacePane.test.tsx": 6, + "src/renderer/src/components/settings/GeneralPane.test.ts": 11, + "src/renderer/src/components/settings/GeneralRemoteServerUpdates.test.tsx": 55, + "src/renderer/src/components/settings/GeneralUpdateSettingsSection.test.tsx": 79, + "src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.test.tsx": 308, + "src/renderer/src/components/settings/GhosttyImportModal.test.ts": 17, + "src/renderer/src/components/settings/GitPane.test.ts": 54, + "src/renderer/src/components/settings/GitPane.test.tsx": 109, + "src/renderer/src/components/settings/GrokAccountsSection.test.tsx": 61, + "src/renderer/src/components/settings/HiddenExperimentalGroup.test.tsx": 85, + "src/renderer/src/components/settings/KagiSessionLinkForm.test.ts": 5, + "src/renderer/src/components/settings/LinearAgentSkillGuide.test.tsx": 49, + "src/renderer/src/components/settings/LinearAgentSkillNotes.test.tsx": 27, + "src/renderer/src/components/settings/LinearAgentSkillPane.test.tsx": 152, + "src/renderer/src/components/settings/LocalNetworkConnectionTest.test.tsx": 227, + "src/renderer/src/components/settings/MobileEmulatorAgentControlRow.test.tsx": 40, + "src/renderer/src/components/settings/MobilePairingConnectionOptions.test.tsx": 243, + "src/renderer/src/components/settings/MobilePairingQrSection.test.tsx": 203, + "src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx": 748, + "src/renderer/src/components/settings/MobilePane.test.tsx": 885, + "src/renderer/src/components/settings/MobilePaneAddressSearch.test.tsx": 31, + "src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx": 114, + "src/renderer/src/components/settings/NotificationsPane.test.tsx": 55, + "src/renderer/src/components/settings/OpenInMenuSetting.test.ts": 23, + "src/renderer/src/components/settings/OrcaAccountSettingsPane.test.tsx": 140, + "src/renderer/src/components/settings/OrchestrationPane.test.tsx": 266, + "src/renderer/src/components/settings/OrchestrationSkillAgentCoverage.test.tsx": 26, + "src/renderer/src/components/settings/PluginConsentDialog.test.tsx": 450, + "src/renderer/src/components/settings/PluginInstallDialog.test.tsx": 165, + "src/renderer/src/components/settings/PluginKeybindingConsentPreview.test.ts": 7, + "src/renderer/src/components/settings/PluginMarketplaceBrowser.test.tsx": 397, + "src/renderer/src/components/settings/PluginMarketplaceSourceDialog.test.tsx": 195, + "src/renderer/src/components/settings/PluginSettingsRow.test.tsx": 75, + "src/renderer/src/components/settings/PluginsSettingsSection.lifecycle.test.tsx": 748, + "src/renderer/src/components/settings/PrivacyPane.test.ts": 47, + "src/renderer/src/components/settings/ProjectWindowsRuntimeSetting.test.tsx": 159, + "src/renderer/src/components/settings/QuickCommandsList.test.tsx": 122, + "src/renderer/src/components/settings/QuickCommandsPane.test.ts": 8, + "src/renderer/src/components/settings/RemoteServerUpdateDialog.test.tsx": 69, + "src/renderer/src/components/settings/RepositoryForkSyncSection.test.tsx": 62, + "src/renderer/src/components/settings/RepositoryHooksSection.test.ts": 120, + "src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx": 354, + "src/renderer/src/components/settings/RepositoryHostSetupsSection.workspace-window.test.tsx": 71, + "src/renderer/src/components/settings/RepositoryIconEmojiPicker.test.tsx": 37, + "src/renderer/src/components/settings/RepositoryIconPicker.github-avatar-refresh.test.tsx": 47, + "src/renderer/src/components/settings/RepositoryPane.test.ts": 837, + "src/renderer/src/components/settings/RepositoryPaneDraftInput.test.tsx": 74, + "src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts": 73, + "src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.test.tsx": 130, + "src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts": 17, + "src/renderer/src/components/settings/RuntimeHostAccessForm.test.tsx": 75, + "src/renderer/src/components/settings/RuntimePairingGeneratorForm.test.tsx": 41, + "src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx": 88, + "src/renderer/src/components/settings/Settings.load-performance.test.ts": 7, + "src/renderer/src/components/settings/SettingsConstants.test.ts": 9, + "src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx": 359, + "src/renderer/src/components/settings/SettingsFormControls.number-field.test.tsx": 66, + "src/renderer/src/components/settings/SettingsFormControls.segmented-control.test.tsx": 69, + "src/renderer/src/components/settings/SettingsSidebar.test.tsx": 79, + "src/renderer/src/components/settings/ShareSkillsSettingsPane.test.tsx": 192, + "src/renderer/src/components/settings/ShortcutCommandBlock.test.tsx": 76, + "src/renderer/src/components/settings/ShortcutFilterRail.test.ts": 10, + "src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.test.tsx": 137, + "src/renderer/src/components/settings/SparsePresetSettingsSection.test.tsx": 28, + "src/renderer/src/components/settings/SshTargetCard.test.tsx": 89, + "src/renderer/src/components/settings/SshTargetForm.test.tsx": 363, + "src/renderer/src/components/settings/TaskSourceLinearSetup.test.tsx": 77, + "src/renderer/src/components/settings/TaskSourceProviderCard.test.tsx": 72, + "src/renderer/src/components/settings/TaskSourceShowInTasksStep.test.tsx": 32, + "src/renderer/src/components/settings/TaskSourceSimpleSetup.test.tsx": 40, + "src/renderer/src/components/settings/TasksPane.test.tsx": 282, + "src/renderer/src/components/settings/TerminalAdvancedSection.test.tsx": 85, + "src/renderer/src/components/settings/TerminalAppearanceSection.ghostty.test.ts": 48, + "src/renderer/src/components/settings/TerminalContrastSetting.test.tsx": 128, + "src/renderer/src/components/settings/TerminalFontSizeSetting.test.tsx": 62, + "src/renderer/src/components/settings/TerminalPane.pwsh.test.ts": 38, + "src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx": 19, + "src/renderer/src/components/settings/TerminalTccAttributionNotice.test.tsx": 89, + "src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts": 51, + "src/renderer/src/components/settings/VoicePane.test.tsx": 443, + "src/renderer/src/components/settings/VoiceSpeechModelSection.test.tsx": 85, + "src/renderer/src/components/settings/WorkspaceDirectorySetting.test.tsx": 104, + "src/renderer/src/components/settings/accounts-search.test.ts": 7, + "src/renderer/src/components/settings/agent-availability-settings.test.ts": 19, + "src/renderer/src/components/settings/agent-default-env-draft.test.ts": 9, + "src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts": 103, + "src/renderer/src/components/settings/appearance-interface-summary.test.ts": 7, + "src/renderer/src/components/settings/appearance-search.test.ts": 315, + "src/renderer/src/components/settings/appearance-status-bar-search.test.ts": 8, + "src/renderer/src/components/settings/appearance-usage-percentage-search.test.ts": 3, + "src/renderer/src/components/settings/browser-cookie-import-label.test.ts": 9, + "src/renderer/src/components/settings/browser-link-routing-localization.test.ts": 222, + "src/renderer/src/components/settings/browser-search.test.ts": 60, + "src/renderer/src/components/settings/browser-session-host-selection.test.ts": 4, + "src/renderer/src/components/settings/cli-install-failure.test.ts": 7, + "src/renderer/src/components/settings/cli-source-control-integration-cards.test.tsx": 41, + "src/renderer/src/components/settings/codex-account-auth-warning.test.ts": 11, + "src/renderer/src/components/settings/codex-config-sync-warning.test.ts": 7, + "src/renderer/src/components/settings/codex-session-source-home-control.test.tsx": 10, + "src/renderer/src/components/settings/developer-permissions-search.test.ts": 280, + "src/renderer/src/components/settings/host-scoped-setting-scope.test.ts": 9, + "src/renderer/src/components/settings/integrations-pane-status.test.ts": 10, + "src/renderer/src/components/settings/jira-integration-card.test.tsx": 71, + "src/renderer/src/components/settings/linear-agent-skill-install-cta.test.tsx": 126, + "src/renderer/src/components/settings/mobile-network-interface-selection.test.ts": 9, + "src/renderer/src/components/settings/mobile-pairing-device-polling.test.ts": 6, + "src/renderer/src/components/settings/mobile-pane-search.test.ts": 9, + "src/renderer/src/components/settings/native-chat-experimental-search-entry.test.ts": 11, + "src/renderer/src/components/settings/osc52-clipboard-copy.test.ts": 7, + "src/renderer/src/components/settings/plugin-install-source.test.ts": 9, + "src/renderer/src/components/settings/provider-account-scope.test.ts": 12, + "src/renderer/src/components/settings/provider-account-visibility.test.ts": 5, + "src/renderer/src/components/settings/provider-rate-limit-scope-panels.test.tsx": 31, + "src/renderer/src/components/settings/repository-hook-settings-draft.test.ts": 7, + "src/renderer/src/components/settings/repository-host-setup-options.test.ts": 6, + "src/renderer/src/components/settings/repository-icon-github.test.ts": 8, + "src/renderer/src/components/settings/repository-source-control-ai-global-ux.test.ts": 52, + "src/renderer/src/components/settings/repository-source-control-ai-persist-queue.test.ts": 10, + "src/renderer/src/components/settings/setting-ownership.test.ts": 13, + "src/renderer/src/components/settings/settings-deep-link-target-watcher.test.ts": 18, + "src/renderer/src/components/settings/settings-form-option-filter.test.ts": 13, + "src/renderer/src/components/settings/settings-project-list.test.ts": 21, + "src/renderer/src/components/settings/settings-search-keywords.test.ts": 122, + "src/renderer/src/components/settings/settings-search.test.ts": 9, + "src/renderer/src/components/settings/settings-setup-guide-progress-hook.test.tsx": 12, + "src/renderer/src/components/settings/settings-setup-guide-progress.test.ts": 5, + "src/renderer/src/components/settings/shortcut-binding-list-mutations.test.ts": 10, + "src/renderer/src/components/settings/shortcut-definition-catalog.test.ts": 269, + "src/renderer/src/components/settings/shortcut-groups.test.ts": 14, + "src/renderer/src/components/settings/shortcut-recording-state.test.ts": 5, + "src/renderer/src/components/settings/shortcut-row-visibility.test.ts": 14, + "src/renderer/src/components/settings/sparse-preset-date.test.ts": 26, + "src/renderer/src/components/settings/sparse-preset-operation-error.test.ts": 3, + "src/renderer/src/components/settings/ssh-target-action-state.test.ts": 4, + "src/renderer/src/components/settings/ssh-target-draft.test.ts": 17, + "src/renderer/src/components/settings/ssh-target-remove.test.ts": 10, + "src/renderer/src/components/settings/ssh-target-save-payload.test.ts": 10, + "src/renderer/src/components/settings/task-source-setup-state.test.ts": 11, + "src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx": 134, + "src/renderer/src/components/settings/terminal-preview-content.test.ts": 7, + "src/renderer/src/components/settings/terminal-search.test.ts": 56, + "src/renderer/src/components/settings/use-debounced-settings-text-draft.test.ts": 35, + "src/renderer/src/components/settings/use-mac-captured-digit-chords.test.ts": 393, + "src/renderer/src/components/settings/use-repository-hook-settings-draft.test.tsx": 34, + "src/renderer/src/components/settings/use-task-source-provider-readiness.test.tsx": 25, + "src/renderer/src/components/settings/useGhosttyImport.test.ts": 17, + "src/renderer/src/components/settings/useWarpThemeImport.test.ts": 31, + "src/renderer/src/components/settings/worktree-symlink-path-filter.test.ts": 11, + "src/renderer/src/components/setup-guide/SetupGuideModal.mount-gating.test.tsx": 1073, + "src/renderer/src/components/setup-guide/use-setup-guide-progress.test.ts": 10, + "src/renderer/src/components/setup-guide/use-setup-guide-telemetry.test.ts": 10, + "src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts": 184, + "src/renderer/src/components/shared/useDaemonActions.test.tsx": 27, + "src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx": 198, + "src/renderer/src/components/sidebar/AddRemoteHostDialog.config-picker.test.tsx": 603, + "src/renderer/src/components/sidebar/AddRemoteHostFields.test.tsx": 39, + "src/renderer/src/components/sidebar/AddRepoCreateStep.test.tsx": 54, + "src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts": 8, + "src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx": 71, + "src/renderer/src/components/sidebar/AddRepoHostSelector.test.tsx": 43, + "src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx": 146, + "src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx": 283, + "src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts": 103, + "src/renderer/src/components/sidebar/AgentDashboardSidebarHost.test.tsx": 40, + "src/renderer/src/components/sidebar/AutoRenameFailedDialog.test.tsx": 229, + "src/renderer/src/components/sidebar/CacheTimer.test.tsx": 15, + "src/renderer/src/components/sidebar/CommentMarkdown.github-attachment-image.test.tsx": 68, + "src/renderer/src/components/sidebar/CommentMarkdown.link-click.test.tsx": 134, + "src/renderer/src/components/sidebar/CommentMarkdown.test.tsx": 309, + "src/renderer/src/components/sidebar/DeleteWorktreeDialog.host-context-boundary.test.ts": 7, + "src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx": 523, + "src/renderer/src/components/sidebar/DeleteWorktreeTargetPreview.test.tsx": 105, + "src/renderer/src/components/sidebar/FilterToggleRow.test.tsx": 30, + "src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.test.tsx": 34, + "src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.reminder-toast.test.tsx": 214, + "src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx": 820, + "src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.update-command.test.tsx": 242, + "src/renderer/src/components/sidebar/MarkdownImageLightbox.test.tsx": 265, + "src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.test.tsx": 109, + "src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx": 80, + "src/renderer/src/components/sidebar/NoticeHostGlyph.test.tsx": 59, + "src/renderer/src/components/sidebar/OrcaYamlTrustDialog.test.tsx": 24, + "src/renderer/src/components/sidebar/ProjectAddedDialog.test.tsx": 37, + "src/renderer/src/components/sidebar/ProjectGroupDeleteDialog.test.tsx": 194, + "src/renderer/src/components/sidebar/ProjectHeaderActions.test.tsx": 17, + "src/renderer/src/components/sidebar/RemoteFileBrowser.paste.test.tsx": 145, + "src/renderer/src/components/sidebar/RemoveFolderDialog.test.tsx": 15, + "src/renderer/src/components/sidebar/SetupGuideSidebarEntry.test.tsx": 65, + "src/renderer/src/components/sidebar/SetupScriptPromptCard.test.ts": 8, + "src/renderer/src/components/sidebar/SetupScriptPromptCardShell.test.tsx": 57, + "src/renderer/src/components/sidebar/Sidebar.test.tsx": 80, + "src/renderer/src/components/sidebar/SidebarAgentsList.test.tsx": 205, + "src/renderer/src/components/sidebar/SidebarFeedbackDialog.test.tsx": 312, + "src/renderer/src/components/sidebar/SidebarGroupByToggle.test.tsx": 62, + "src/renderer/src/components/sidebar/SidebarHeader.test.tsx": 200, + "src/renderer/src/components/sidebar/SidebarNav.test.tsx": 749, + "src/renderer/src/components/sidebar/SidebarRepositoryFilterSection.test.tsx": 507, + "src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx": 280, + "src/renderer/src/components/sidebar/SidebarToolbar.test.tsx": 129, + "src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.test.tsx": 123, + "src/renderer/src/components/sidebar/StatusIndicator.test.ts": 32, + "src/renderer/src/components/sidebar/WorkspaceKanbanCard.host-identity.test.tsx": 30, + "src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.mount-gating.test.tsx": 522, + "src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.search.test.tsx": 179, + "src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.task-status-sync.test.tsx": 128, + "src/renderer/src/components/sidebar/WorkspaceKanbanDrawerHeader.test.tsx": 14, + "src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.test.tsx": 70, + "src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.test.tsx": 78, + "src/renderer/src/components/sidebar/WorkspaceKanbanSearchField.test.tsx": 80, + "src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.test.tsx": 211, + "src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.test.tsx": 55, + "src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx": 80, + "src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx": 3366, + "src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx": 1185, + "src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx": 1294, + "src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx": 1219, + "src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx": 1041, + "src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx": 1063, + "src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx": 1918, + "src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx": 1401, + "src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx": 762, + "src/renderer/src/components/sidebar/WorktreeCard.test.ts": 9, + "src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx": 523, + "src/renderer/src/components/sidebar/WorktreeCardAgents.expansion-remount.test.tsx": 470, + "src/renderer/src/components/sidebar/WorktreeCardAgents.send-target.test.tsx": 808, + "src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx": 673, + "src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.test.tsx": 86, + "src/renderer/src/components/sidebar/WorktreeCardDisplayMenuSection.test.tsx": 76, + "src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx": 305, + "src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx": 99, + "src/renderer/src/components/sidebar/WorktreeCardPorts.test.tsx": 1109, + "src/renderer/src/components/sidebar/WorktreeCardSshHostControl.test.tsx": 281, + "src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx": 53, + "src/renderer/src/components/sidebar/WorktreeContextMenu.delete-shortcut.test.tsx": 70, + "src/renderer/src/components/sidebar/WorktreeContextMenu.react185-bystander.test.tsx": 733, + "src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts": 31, + "src/renderer/src/components/sidebar/WorktreeDeveloperMenu.test.tsx": 23, + "src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx": 73, + "src/renderer/src/components/sidebar/WorktreeList.card-memo-stability.test.tsx": 1046, + "src/renderer/src/components/sidebar/WorktreeList.empty-project-rows.test.ts": 1389, + "src/renderer/src/components/sidebar/WorktreeList.folder-workspace-rows.test.ts": 1843, + "src/renderer/src/components/sidebar/WorktreeList.group-headers.test.ts": 1975, + "src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx": 2544, + "src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts": 1842, + "src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx": 3279, + "src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx": 2529, + "src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx": 1032, + "src/renderer/src/components/sidebar/WorktreeOpenInMenu.test.tsx": 30, + "src/renderer/src/components/sidebar/WorktreeParentPickerPopover.test.ts": 21, + "src/renderer/src/components/sidebar/WorktreeTitleInlineRename.begin-editing.test.tsx": 78, + "src/renderer/src/components/sidebar/WorktreeTitleInlineRename.editor-lifecycle.test.tsx": 147, + "src/renderer/src/components/sidebar/WorktreeTitleInlineRename.test.tsx": 24, + "src/renderer/src/components/sidebar/WorktreeVisibilityDialog.test.tsx": 2347, + "src/renderer/src/components/sidebar/WorktreeVisibilityHelpPopover.test.tsx": 496, + "src/renderer/src/components/sidebar/WorktreeVisibilitySourceList.test.tsx": 107, + "src/renderer/src/components/sidebar/active-worktree-focus-after-delete.test.ts": 13, + "src/renderer/src/components/sidebar/add-remote-host-ssh-actions.test.ts": 18, + "src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts": 9, + "src/renderer/src/components/sidebar/add-repo-existing-workspaces-telemetry.test.ts": 15, + "src/renderer/src/components/sidebar/add-repo-skip-finalization.test.ts": 6, + "src/renderer/src/components/sidebar/clone-defaults.test.ts": 6, + "src/renderer/src/components/sidebar/create-project-defaults.test.ts": 7, + "src/renderer/src/components/sidebar/default-branch-visible-under-hide-sleeping.test.ts": 12, + "src/renderer/src/components/sidebar/delete-worktree-dirty-change-counts.test.ts": 3, + "src/renderer/src/components/sidebar/delete-worktree-failure-toast.test.tsx": 44, + "src/renderer/src/components/sidebar/delete-worktree-flow.test.ts": 284, + "src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts": 37, + "src/renderer/src/components/sidebar/delete-worktree-toast.test.ts": 8, + "src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts": 10, + "src/renderer/src/components/sidebar/focused-agent-row-highlight.test.ts": 10, + "src/renderer/src/components/sidebar/folder-workspace-card-pr-display.test.ts": 18, + "src/renderer/src/components/sidebar/folder-workspace-composer-helpers.test.ts": 10, + "src/renderer/src/components/sidebar/folder-workspace-composer-path-status.test.tsx": 48, + "src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts": 61, + "src/renderer/src/components/sidebar/folder-workspace-linked-startup-plan.test.ts": 6, + "src/renderer/src/components/sidebar/host-header-drag.test.tsx": 51, + "src/renderer/src/components/sidebar/host-header-menu-items.test.ts": 8, + "src/renderer/src/components/sidebar/host-rename-remove.test.ts": 8, + "src/renderer/src/components/sidebar/host-section-folder-workspace-counts.test.ts": 7, + "src/renderer/src/components/sidebar/host-section-order.test.ts": 5, + "src/renderer/src/components/sidebar/host-section-rows.test.ts": 12, + "src/renderer/src/components/sidebar/hovered-workspace-delete.test.ts": 115, + "src/renderer/src/components/sidebar/imported-worktrees-card-actions.test.ts": 13, + "src/renderer/src/components/sidebar/imported-worktrees-card-candidates.test.ts": 8, + "src/renderer/src/components/sidebar/linear-agent-skill-runtime.shell-override.test.ts": 5, + "src/renderer/src/components/sidebar/linear-agent-skill-setup-reminders.test.ts": 13, + "src/renderer/src/components/sidebar/local-base-ref-suggestion-toast.test.tsx": 52, + "src/renderer/src/components/sidebar/mobile-sidebar-onboarding-badge.test.ts": 34, + "src/renderer/src/components/sidebar/natural-worktree-ids.test.ts": 8, + "src/renderer/src/components/sidebar/new-external-worktrees-inbox-actions.test.ts": 10, + "src/renderer/src/components/sidebar/new-external-worktrees-inbox-candidates.test.ts": 7, + "src/renderer/src/components/sidebar/pinned-section-worktrees.test.ts": 363, + "src/renderer/src/components/sidebar/preserved-branch-batch-toast.test.tsx": 129, + "src/renderer/src/components/sidebar/preserved-branch-toast.test.tsx": 47, + "src/renderer/src/components/sidebar/project-added-default-checkout.test.ts": 22, + "src/renderer/src/components/sidebar/project-group-header-dom.test.ts": 6, + "src/renderer/src/components/sidebar/project-group-header-drag-commit.test.ts": 12, + "src/renderer/src/components/sidebar/project-group-header-drag-start.test.ts": 21, + "src/renderer/src/components/sidebar/project-group-header-drag.test.ts": 16, + "src/renderer/src/components/sidebar/project-group-header-drop.test.ts": 13, + "src/renderer/src/components/sidebar/project-header-action-selector-lockstep.test.ts": 7, + "src/renderer/src/components/sidebar/project-header-color.test.ts": 7, + "src/renderer/src/components/sidebar/project-header-drag-commit.test.ts": 12, + "src/renderer/src/components/sidebar/project-header-drag-start.test.ts": 11, + "src/renderer/src/components/sidebar/project-header-drag.test.ts": 14, + "src/renderer/src/components/sidebar/project-header-drop.test.ts": 7, + "src/renderer/src/components/sidebar/project-order-manual-default-notice-visibility.test.ts": 7, + "src/renderer/src/components/sidebar/prompt-cache-countdown-clock.test.ts": 11, + "src/renderer/src/components/sidebar/prompt-cache-timer-selection.test.ts": 5, + "src/renderer/src/components/sidebar/remote-file-browser-drive-paths.test.ts": 6, + "src/renderer/src/components/sidebar/remote-file-browser-helpers.test.ts": 39, + "src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.test.ts": 42, + "src/renderer/src/components/sidebar/repo-header-create-state.test.ts": 10, + "src/renderer/src/components/sidebar/sidebar-empty-state-gate.test.ts": 4, + "src/renderer/src/components/sidebar/sidebar-filter-state.test.ts": 9, + "src/renderer/src/components/sidebar/sidebar-host-options.test.ts": 15, + "src/renderer/src/components/sidebar/sidebar-project-drop.test.ts": 10, + "src/renderer/src/components/sidebar/sidebar-resize-handle.test.ts": 8, + "src/renderer/src/components/sidebar/sidebar-workspace-option-items.test.ts": 9, + "src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts": 17, + "src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts": 19, + "src/renderer/src/components/sidebar/smart-attention.test.ts": 26, + "src/renderer/src/components/sidebar/smart-sort.test.ts": 17, + "src/renderer/src/components/sidebar/ssh-host-remove-resolution.test.ts": 10, + "src/renderer/src/components/sidebar/ssh-target-duplicate.test.ts": 8, + "src/renderer/src/components/sidebar/ssh-workspace-forget-resolution.test.ts": 5, + "src/renderer/src/components/sidebar/stale-agent-row-unverifiable.test.ts": 13, + "src/renderer/src/components/sidebar/truncated-sidebar-label.test.tsx": 63, + "src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts": 157, + "src/renderer/src/components/sidebar/use-add-repo-hosted-controller.test.ts": 5, + "src/renderer/src/components/sidebar/use-feedback-image-drop.test.tsx": 46, + "src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.test.tsx": 28, + "src/renderer/src/components/sidebar/use-workspace-kanban-card-pointer-drag.test.ts": 10, + "src/renderer/src/components/sidebar/use-workspace-kanban-drawer-lingering.test.tsx": 28, + "src/renderer/src/components/sidebar/use-workspace-kanban-outside-dismiss.test.ts": 7, + "src/renderer/src/components/sidebar/use-workspace-kanban-selection.test.tsx": 21, + "src/renderer/src/components/sidebar/use-workspace-reveal-body-redirect.test.tsx": 33, + "src/renderer/src/components/sidebar/use-workspace-status-drop.test.ts": 7, + "src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx": 23, + "src/renderer/src/components/sidebar/use-worktree-activity-statuses.test.ts": 7, + "src/renderer/src/components/sidebar/use-worktree-card-secondary-details.store-subscriptions.test.tsx": 30, + "src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts": 114, + "src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts": 127, + "src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts": 13, + "src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts": 54, + "src/renderer/src/components/sidebar/useCreateProjectDefaults.test.ts": 30, + "src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts": 105, + "src/renderer/src/components/sidebar/useRenderedSetupScriptPromptState.test.ts": 20, + "src/renderer/src/components/sidebar/useSetupScriptPromptRevalidation.test.tsx": 63, + "src/renderer/src/components/sidebar/useWorkspaceBoardPanel.test.tsx": 68, + "src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts": 21, + "src/renderer/src/components/sidebar/visible-worktree-activity-inputs.test.ts": 9, + "src/renderer/src/components/sidebar/visible-worktree-indexes.test.ts": 12, + "src/renderer/src/components/sidebar/visible-worktrees.test.ts": 22, + "src/renderer/src/components/sidebar/workspace-board-task-status-sync.test.ts": 26, + "src/renderer/src/components/sidebar/workspace-creator-visibility.test.ts": 6, + "src/renderer/src/components/sidebar/workspace-delete-lineage.test.ts": 9, + "src/renderer/src/components/sidebar/workspace-kanban-area-selection.test.ts": 13, + "src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.test.ts": 7, + "src/renderer/src/components/sidebar/workspace-kanban-filtered-drop-index.test.ts": 11, + "src/renderer/src/components/sidebar/workspace-kanban-search.test.ts": 23, + "src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts": 21, + "src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.test.ts": 13, + "src/renderer/src/components/sidebar/workspace-kanban-worktree-groups.test.ts": 16, + "src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts": 9, + "src/renderer/src/components/sidebar/workspace-status.test.ts": 11, + "src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts": 25, + "src/renderer/src/components/sidebar/worktree-agent-freshness-selector.test.ts": 10, + "src/renderer/src/components/sidebar/worktree-agent-orchestration-batch.test.ts": 17, + "src/renderer/src/components/sidebar/worktree-agent-orchestration-index.test.ts": 103, + "src/renderer/src/components/sidebar/worktree-agent-row-selectors.test.ts": 26, + "src/renderer/src/components/sidebar/worktree-card-agent-ack-inputs.test.tsx": 20, + "src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts": 31, + "src/renderer/src/components/sidebar/worktree-card-compact-agent-row.stable-message.test.tsx": 84, + "src/renderer/src/components/sidebar/worktree-card-details-hover-state.test.tsx": 26, + "src/renderer/src/components/sidebar/worktree-card-dom-events.test.ts": 6, + "src/renderer/src/components/sidebar/worktree-card-jira-issue-display.test.ts": 6, + "src/renderer/src/components/sidebar/worktree-card-markdown-isolation.test.ts": 344, + "src/renderer/src/components/sidebar/worktree-card-pr-display.test.ts": 11, + "src/renderer/src/components/sidebar/worktree-card-send-target-inputs.test.ts": 7, + "src/renderer/src/components/sidebar/worktree-card-status-inputs.test.ts": 8, + "src/renderer/src/components/sidebar/worktree-card-title-display.test.ts": 10, + "src/renderer/src/components/sidebar/worktree-context-menu-delete-intent.test.ts": 11, + "src/renderer/src/components/sidebar/worktree-delete-host-qualification.test.ts": 173, + "src/renderer/src/components/sidebar/worktree-delete-position-scaling.test.ts": 35, + "src/renderer/src/components/sidebar/worktree-delete-request.test.ts": 9, + "src/renderer/src/components/sidebar/worktree-drag-units.test.ts": 10, + "src/renderer/src/components/sidebar/worktree-filter-visibility.test.ts": 8, + "src/renderer/src/components/sidebar/worktree-header-section-boundaries.test.ts": 6, + "src/renderer/src/components/sidebar/worktree-keyboard-cycle.test.ts": 7, + "src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts": 15, + "src/renderer/src/components/sidebar/worktree-lineage-expansion.performance.test.ts": 159, + "src/renderer/src/components/sidebar/worktree-lineage-projection.test.ts": 10, + "src/renderer/src/components/sidebar/worktree-lineage-toggle-handler-cache.test.ts": 10, + "src/renderer/src/components/sidebar/worktree-list-groups-host-collision.test.ts": 17, + "src/renderer/src/components/sidebar/worktree-list-groups-host-labels.test.ts": 23, + "src/renderer/src/components/sidebar/worktree-list-groups-imported-worktrees.test.ts": 22, + "src/renderer/src/components/sidebar/worktree-list-groups-lineage-nesting.test.ts": 17, + "src/renderer/src/components/sidebar/worktree-list-groups-nested-project-groups.test.ts": 16, + "src/renderer/src/components/sidebar/worktree-list-groups-notice-host-labels.test.ts": 16, + "src/renderer/src/components/sidebar/worktree-list-groups-pending-creations.test.ts": 13, + "src/renderer/src/components/sidebar/worktree-list-groups-pinned-host-labels.test.ts": 11, + "src/renderer/src/components/sidebar/worktree-list-groups-project-groups.test.ts": 16, + "src/renderer/src/components/sidebar/worktree-list-groups-project-host-setups.test.ts": 19, + "src/renderer/src/components/sidebar/worktree-list-groups-project-order.test.ts": 1284, + "src/renderer/src/components/sidebar/worktree-list-groups-section-label-disambiguation.test.ts": 12, + "src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.folder-workspace-lanes.test.ts": 22, + "src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.test.ts": 21, + "src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.test.ts": 8, + "src/renderer/src/components/sidebar/worktree-list/grouping/imported-rows.test.ts": 9, + "src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts": 9, + "src/renderer/src/components/sidebar/worktree-list/listing/pending-worktree-creation-keys.test.ts": 5, + "src/renderer/src/components/sidebar/worktree-list/listing/review-cache-inputs.test.ts": 6, + "src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.test.tsx": 35, + "src/renderer/src/components/sidebar/worktree-list/navigation/active-descendant-option.test.ts": 5, + "src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.test.ts": 9, + "src/renderer/src/components/sidebar/worktree-list/navigation/render-row-lookup.folder-workspace.test.ts": 5, + "src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.host-identity.test.tsx": 24, + "src/renderer/src/components/sidebar/worktree-list/navigation/use-reveal-requests.test.tsx": 124, + "src/renderer/src/components/sidebar/worktree-list/navigation/use-selection-host-collision.test.tsx": 18, + "src/renderer/src/components/sidebar/worktree-list/rows/FolderPathStatusIndicator.test.tsx": 38, + "src/renderer/src/components/sidebar/worktree-list/rows/RepoScanUnavailableIndicator.test.tsx": 57, + "src/renderer/src/components/sidebar/worktree-list/rows/indentation.test.ts": 8, + "src/renderer/src/components/sidebar/worktree-list/rows/option-dom-host-collision.test.ts": 11, + "src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs-owner-host.test.tsx": 39, + "src/renderer/src/components/sidebar/worktree-list/viewport/hard-scroll-up.test.ts": 14, + "src/renderer/src/components/sidebar/worktree-list/viewport/scroll-adjustment.test.ts": 15, + "src/renderer/src/components/sidebar/worktree-list/viewport/sticky-headers.test.ts": 26, + "src/renderer/src/components/sidebar/worktree-list/viewport/use-row-removal-animation.test.ts": 8, + "src/renderer/src/components/sidebar/worktree-list/viewport/use-scroll-to-top.test.ts": 45, + "src/renderer/src/components/sidebar/worktree-list/viewport/use-scroll-to-top.test.tsx": 21, + "src/renderer/src/components/sidebar/worktree-list/viewport/virtual-rows.test.ts": 8, + "src/renderer/src/components/sidebar/worktree-list/viewport/visible-refresh.test.ts": 8, + "src/renderer/src/components/sidebar/worktree-manual-order-catalog.test.ts": 16, + "src/renderer/src/components/sidebar/worktree-manual-order-store-write.test.ts": 22, + "src/renderer/src/components/sidebar/worktree-manual-order.test.ts": 315, + "src/renderer/src/components/sidebar/worktree-meta-updates.test.ts": 14, + "src/renderer/src/components/sidebar/worktree-multi-selection.test.ts": 8, + "src/renderer/src/components/sidebar/worktree-name-suggestions.test.ts": 73, + "src/renderer/src/components/sidebar/worktree-parent-eligibility.test.ts": 9, + "src/renderer/src/components/sidebar/worktree-review-helpers.test.tsx": 22, + "src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts": 4, + "src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts": 15, + "src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.test.ts": 27, + "src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts": 13, + "src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.test.ts": 7, + "src/renderer/src/components/sidebar/worktree-sidebar-reveal-scroll-settle.test.ts": 3, + "src/renderer/src/components/sidebar/worktree-sidebar-reveal.test.ts": 10, + "src/renderer/src/components/sidebar/worktree-sidebar-row-preference.test.ts": 7, + "src/renderer/src/components/sidebar/worktree-sort-label-ordering.test.ts": 180, + "src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts": 20, + "src/renderer/src/components/sidebar/worktree-unambiguous-id-index.test.ts": 5, + "src/renderer/src/components/sidebar/worktree-unnest.test.ts": 19, + "src/renderer/src/components/sidebar/worktree-visibility-source-provenance.test.ts": 9, + "src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx": 53, + "src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx": 51, + "src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx": 420, + "src/renderer/src/components/skills/SkillInstallAgentPicker.test.tsx": 226, + "src/renderer/src/components/skills/SkillInstallDialog.test.tsx": 1326, + "src/renderer/src/components/skills/SkillInstallManagementDialog.test.tsx": 1515, + "src/renderer/src/components/skills/SkillInstallTargetFields.test.tsx": 169, + "src/renderer/src/components/skills/SkillInstallWorkspaceCombobox.test.tsx": 537, + "src/renderer/src/components/skills/SkillShareDialog.test.tsx": 649, + "src/renderer/src/components/skills/SkillSharedLinkRow.test.tsx": 388, + "src/renderer/src/components/skills/SkillsPage.test.tsx": 1126, + "src/renderer/src/components/skills/skill-bundle-name.test.ts": 8, + "src/renderer/src/components/skills/skill-bundle-retry-selection.test.ts": 6, + "src/renderer/src/components/skills/skill-delete-copy.test.ts": 23, + "src/renderer/src/components/skills/skill-delete-selection.test.ts": 10, + "src/renderer/src/components/skills/skill-description-length.test.ts": 4, + "src/renderer/src/components/skills/skill-freshness-grouping.test.ts": 18, + "src/renderer/src/components/skills/skill-freshness-skipped-reason.test.ts": 11, + "src/renderer/src/components/skills/skill-install-progress-state.test.tsx": 23, + "src/renderer/src/components/skills/skill-install-provider-groups.test.ts": 11, + "src/renderer/src/components/skills/skill-install-workspace-choices.test.ts": 8, + "src/renderer/src/components/skills/skill-package-checklist-items.test.ts": 12, + "src/renderer/src/components/skills/skill-package-digest.test.ts": 5, + "src/renderer/src/components/skills/skill-package-install-risk.test.ts": 8, + "src/renderer/src/components/skills/skill-share-link.test.ts": 4, + "src/renderer/src/components/skills/skill-share-package-selection.test.ts": 5, + "src/renderer/src/components/skills/skill-share-preview-summary.test.ts": 21, + "src/renderer/src/components/skills/skill-share-selection.test.ts": 6, + "src/renderer/src/components/skills/skill-source-inventory.test.ts": 8, + "src/renderer/src/components/skills/skills-filter.test.ts": 5, + "src/renderer/src/components/source-control/SourceControlActionVariableChips.test.tsx": 56, + "src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.test.tsx": 86, + "src/renderer/src/components/star-nag/StarNagToastHost.test.tsx": 130, + "src/renderer/src/components/stats/GrokUsagePane.test.tsx": 106, + "src/renderer/src/components/stats/UsageBreakdownSection.test.tsx": 35, + "src/renderer/src/components/stats/UsageTrackingPaneShell.test.tsx": 230, + "src/renderer/src/components/stats/usage-daily-chart.test.tsx": 300, + "src/renderer/src/components/stats/usage-overview-model.test.ts": 202, + "src/renderer/src/components/status-bar/CaffeinateStatusSegment.localization.test.tsx": 645, + "src/renderer/src/components/status-bar/PetStatusSegment.layout.test.ts": 4, + "src/renderer/src/components/status-bar/PortsStatusSegment.host-routing.test.tsx": 100, + "src/renderer/src/components/status-bar/PortsStatusSegment.render-stability.test.tsx": 36, + "src/renderer/src/components/status-bar/ResourceUsageStatusSegment.rows.test.tsx": 62, + "src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts": 6, + "src/renderer/src/components/status-bar/RuntimeHostStatusRow.test.tsx": 130, + "src/renderer/src/components/status-bar/SkillUpdateStatusSegment.test.tsx": 64, + "src/renderer/src/components/status-bar/SshStatusSegment.test.ts": 10, + "src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.test.tsx": 137, + "src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx": 102, + "src/renderer/src/components/status-bar/codex-restart-status-summary.test.ts": 6, + "src/renderer/src/components/status-bar/codex-status-sign-in.test.tsx": 830, + "src/renderer/src/components/status-bar/icons.test.tsx": 16, + "src/renderer/src/components/status-bar/inline-usage-bars.test.tsx": 742, + "src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts": 29, + "src/renderer/src/components/status-bar/ports-status-popover-rows.test.tsx": 50, + "src/renderer/src/components/status-bar/provider-account-sync-key.test.ts": 6, + "src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx": 672, + "src/renderer/src/components/status-bar/remote-host-connection-status.test.ts": 10, + "src/renderer/src/components/status-bar/resource-manager-terminal-copy.test.ts": 7, + "src/renderer/src/components/status-bar/resource-manager-worktree-target.test.ts": 5, + "src/renderer/src/components/status-bar/resource-memory-metric-copy.test.ts": 7, + "src/renderer/src/components/status-bar/resource-session-bindings.test.ts": 8, + "src/renderer/src/components/status-bar/resource-session-classification-parity.test.ts": 3, + "src/renderer/src/components/status-bar/resource-session-inventory.test.ts": 9, + "src/renderer/src/components/status-bar/resource-session-kill-confirmation.test.ts": 5, + "src/renderer/src/components/status-bar/resource-session-navigation.test.ts": 10, + "src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts": 10, + "src/renderer/src/components/status-bar/resource-usage-space-scan-ready.test.ts": 7, + "src/renderer/src/components/status-bar/ssh-status-segment-copy.test.ts": 5, + "src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts": 6, + "src/renderer/src/components/status-bar/status-bar-context-menu-policy.test.ts": 4, + "src/renderer/src/components/status-bar/status-bar-copy-localization.test.tsx": 310, + "src/renderer/src/components/status-bar/status-bar-provider-menu-focus.test.tsx": 773, + "src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts": 12, + "src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts": 12, + "src/renderer/src/components/status-bar/tooltip.test.ts": 51, + "src/renderer/src/components/status-bar/usage-error-copy.test.ts": 5, + "src/renderer/src/components/status-bar/usage-percentage-label.test.ts": 4, + "src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts": 6, + "src/renderer/src/components/status-bar/usage-roster-formatting.test.ts": 6, + "src/renderer/src/components/status-bar/usage-roster-row-state.test.ts": 6, + "src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx": 677, + "src/renderer/src/components/status-bar/workspace-space-breakdown-list.test.tsx": 69, + "src/renderer/src/components/status-bar/workspace-space-delete-host-routing.test.ts": 8, + "src/renderer/src/components/status-bar/workspace-space-format.test.ts": 8, + "src/renderer/src/components/status-bar/workspace-space-layout.test.ts": 5, + "src/renderer/src/components/status-bar/workspace-space-manager-source-boundary.test.ts": 8, + "src/renderer/src/components/status-bar/workspace-space-presentation.test.ts": 133, + "src/renderer/src/components/tab-bar/BrowserTab.test.tsx": 1412, + "src/renderer/src/components/tab-bar/ClientHostedBrowserTabRows.test.tsx": 106, + "src/renderer/src/components/tab-bar/EditorFileTab.test.tsx": 840, + "src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx": 505, + "src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts": 37, + "src/renderer/src/components/tab-bar/RecentTabSwitcher.test.tsx": 46, + "src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx": 739, + "src/renderer/src/components/tab-bar/SortableTab.update-depth-probe.test.tsx": 113, + "src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx": 121, + "src/renderer/src/components/tab-bar/TabBar.client-hosted-row-active-state.test.ts": 1116, + "src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts": 2180, + "src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts": 2043, + "src/renderer/src/components/tab-bar/TabBar.windows-shell-remote-runtime.test.ts": 1451, + "src/renderer/src/components/tab-bar/TabBar.windows-shell-ssh-host.test.ts": 1914, + "src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.test.tsx": 1048, + "src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.windows.test.tsx": 477, + "src/renderer/src/components/tab-bar/TabBarCreateEntry.history.test.tsx": 100, + "src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx": 166, + "src/renderer/src/components/tab-bar/TabBarCreateEntry.search.test.tsx": 199, + "src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx": 171, + "src/renderer/src/components/tab-bar/TabBarCreateEntryRow.test.tsx": 102, + "src/renderer/src/components/tab-bar/TabBarQuickCommandItem.test.tsx": 106, + "src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.keyboard.test.ts": 134, + "src/renderer/src/components/tab-bar/TabStripScrollIndicator.test.tsx": 63, + "src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.test.tsx": 36, + "src/renderer/src/components/tab-bar/client-hosted-browser-row-strip-placement.test.ts": 6, + "src/renderer/src/components/tab-bar/drop-indicator.test.ts": 8, + "src/renderer/src/components/tab-bar/editor-tab-local-open-guard.test.ts": 5, + "src/renderer/src/components/tab-bar/group-tab-order.test.ts": 8, + "src/renderer/src/components/tab-bar/middle-button-default-guard.test.ts": 5, + "src/renderer/src/components/tab-bar/native-chat-tab-agent-evidence.test.ts": 9, + "src/renderer/src/components/tab-bar/open-tab-entry-dedupe.test.ts": 8, + "src/renderer/src/components/tab-bar/open-tab-search-retention.test.ts": 20, + "src/renderer/src/components/tab-bar/open-tab-search.test.ts": 35, + "src/renderer/src/components/tab-bar/open-tab-selection-routing.test.ts": 12, + "src/renderer/src/components/tab-bar/query-token-match.test.ts": 7, + "src/renderer/src/components/tab-bar/recent-tab-switching.test.ts": 8, + "src/renderer/src/components/tab-bar/reconcile-order.test.ts": 6, + "src/renderer/src/components/tab-bar/tab-agent-launch-options.test.ts": 15, + "src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts": 13, + "src/renderer/src/components/tab-bar/tab-bar-item-surface.client-hosted-active-state.test.tsx": 14, + "src/renderer/src/components/tab-bar/tab-context-menu-consistency.test.tsx": 8, + "src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts": 35, + "src/renderer/src/components/tab-bar/tab-create-entry-classifier.test.ts": 50, + "src/renderer/src/components/tab-bar/tab-create-entry-file-matches.test.ts": 36, + "src/renderer/src/components/tab-bar/tab-create-entry-forced-search.test.ts": 4, + "src/renderer/src/components/tab-bar/tab-create-entry-history-placement.test.ts": 6, + "src/renderer/src/components/tab-bar/tab-create-entry-local-path.test.ts": 285, + "src/renderer/src/components/tab-bar/tab-create-menu-options.test.ts": 16, + "src/renderer/src/components/tab-bar/tab-move-to-pane-column.test.ts": 12, + "src/renderer/src/components/tab-bar/tab-strip-content-resize-observers.test.ts": 11, + "src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.test.ts": 6, + "src/renderer/src/components/tab-bar/tab-strip-pointer-activation.test.tsx": 35, + "src/renderer/src/components/tab-bar/tab-strip-scroll-metrics.test.ts": 6, + "src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx": 28, + "src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts": 46, + "src/renderer/src/components/tab-bar/terminal-tab-spinner-launch-agent.test.ts": 15, + "src/renderer/src/components/tab-bar/use-open-tab-search.test.ts": 221, + "src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.test.ts": 29, + "src/renderer/src/components/tab-bar/windows-shell-launch.test.ts": 5, + "src/renderer/src/components/tab-bar/windows-shell-menu-visibility.test.ts": 7, + "src/renderer/src/components/tab-group/RetainedPaneHost.test.tsx": 75, + "src/renderer/src/components/tab-group/TabGroupPanel.context-menu.test.ts": 7, + "src/renderer/src/components/tab-group/TabGroupSplitLayout.drag.test.tsx": 128, + "src/renderer/src/components/tab-group/TabGroupSplitLayout.test.ts": 10, + "src/renderer/src/components/tab-group/TabPaneColumnSplitDragOverlay.test.tsx": 7, + "src/renderer/src/components/tab-group/tab-drag-pointer.test.ts": 4, + "src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts": 10, + "src/renderer/src/components/tab-group/tab-drag-retained-guest-passthrough.test.tsx": 60, + "src/renderer/src/components/tab-group/tab-drop-zone.test.ts": 4, + "src/renderer/src/components/tab-group/tab-group-body-anchor.test.ts": 4, + "src/renderer/src/components/tab-group/tab-group-panel-split-target.test.ts": 18, + "src/renderer/src/components/tab-group/tab-insertion.test.ts": 7, + "src/renderer/src/components/tab-group/useTabDragSplit.test.ts": 78, + "src/renderer/src/components/tab-group/useTabGroupCreationCommands.local-shell.test.ts": 679, + "src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.structured-session.test.ts": 269, + "src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.test.tsx": 69, + "src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts": 1235, + "src/renderer/src/components/task-drawer-source-boundary.test.ts": 8, + "src/renderer/src/components/task-page-cache-selectors.test.ts": 11, + "src/renderer/src/components/task-page-checks-pill.test.ts": 5, + "src/renderer/src/components/task-page-default-repo-selection.test.ts": 18, + "src/renderer/src/components/task-page-empty-state.test.ts": 14, + "src/renderer/src/components/task-page-github-dialog-state-authority.test.ts": 10, + "src/renderer/src/components/task-page-github-issue-creation.test.ts": 5, + "src/renderer/src/components/task-page-github-list-scroll-restore.test.ts": 20, + "src/renderer/src/components/task-page-github-resume-cache.test.ts": 17, + "src/renderer/src/components/task-page-github-reviewer-suggestions.test.ts": 6, + "src/renderer/src/components/task-page-github-status-actions.test.ts": 7, + "src/renderer/src/components/task-page-github-status-state.test.ts": 7, + "src/renderer/src/components/task-page-github-task-kind.test.ts": 15, + "src/renderer/src/components/task-page-github-work-item-filter-membership.test.ts": 10, + "src/renderer/src/components/task-page-github-work-item-mutation-patches.test.ts": 13, + "src/renderer/src/components/task-page-github-work-item-mutation-regressions.test.ts": 22, + "src/renderer/src/components/task-page-github-work-item-mutations.test.ts": 24, + "src/renderer/src/components/task-page-github-work-item-status.test.ts": 9, + "src/renderer/src/components/task-page-gitlab-task-filters.test.ts": 5, + "src/renderer/src/components/task-page-initial-selection-scaling.test.tsx": 41, + "src/renderer/src/components/task-page-jira-cache-selectors.test.ts": 7, + "src/renderer/src/components/task-page-jira-create-fields.test.ts": 9, + "src/renderer/src/components/task-page-jira-grouping.test.ts": 194, + "src/renderer/src/components/task-page-jira-item-source-context.test.ts": 7, + "src/renderer/src/components/task-page-jira-load-state.test.ts": 7, + "src/renderer/src/components/task-page-jira-project-selection.test.ts": 8, + "src/renderer/src/components/task-page-jira-sort-controls.test.tsx": 186, + "src/renderer/src/components/task-page-jira-sorting.test.ts": 151, + "src/renderer/src/components/task-page-linear-in-orca-issues.test.ts": 14, + "src/renderer/src/components/task-page-linear-issue-dialog-popover-scroll.test.ts": 9, + "src/renderer/src/components/task-page-linear-issue-empty-state.test.ts": 6, + "src/renderer/src/components/task-page-linear-issue-grouping.test.ts": 11, + "src/renderer/src/components/task-page-linear-issue-request.test.ts": 6, + "src/renderer/src/components/task-page-linear-team-selection.test.ts": 9, + "src/renderer/src/components/task-page-list-chrome-visibility.test.ts": 7, + "src/renderer/src/components/task-page-localized-options.test.ts": 79, + "src/renderer/src/components/task-page-mutation-page-allocation.test.ts": 8, + "src/renderer/src/components/task-page-new-issue-draft.test.ts": 6, + "src/renderer/src/components/task-page-pagination-page-numbers.test.ts": 7, + "src/renderer/src/components/task-page-pr-check-summary.test.ts": 6, + "src/renderer/src/components/task-page-repo-source-context.test.ts": 10, + "src/renderer/src/components/task-page-repo-source-divergence.test.ts": 5, + "src/renderer/src/components/task-page-source-switch-boundary.test.ts": 9, + "src/renderer/src/components/task-page-task-creation-drafts.test.ts": 10, + "src/renderer/src/components/task-page-task-source-host-availability.test.ts": 11, + "src/renderer/src/components/task-page-work-item-pagination.test.ts": 18, + "src/renderer/src/components/task-page-workspace-composer-boundary.test.ts": 223, + "src/renderer/src/components/task-source-context-summary.test.ts": 17, + "src/renderer/src/components/task-source-provider-availability.test.ts": 10, + "src/renderer/src/components/terminal-pane/CloseTerminalDialog.test.tsx": 135, + "src/renderer/src/components/terminal-pane/MobileDriverOverlay.test.tsx": 12, + "src/renderer/src/components/terminal-pane/PinnedTabCloseDialog.test.tsx": 200, + "src/renderer/src/components/terminal-pane/RunningTerminalCloseDialog.test.tsx": 269, + "src/renderer/src/components/terminal-pane/SessionRestoredBanner.test.tsx": 33, + "src/renderer/src/components/terminal-pane/TerminalAgentSessionForkDialog.test.tsx": 207, + "src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx": 58, + "src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts": 127, + "src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx": 101, + "src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.test.tsx": 24, + "src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.react185.test.tsx": 60, + "src/renderer/src/components/terminal-pane/TerminalProcessExitOverlay.test.tsx": 76, + "src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx": 70, + "src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx": 192, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-attention-dispatch.test.ts": 39, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-completion-replay-guard.test.ts": 20, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-dispose-leak.test.ts": 13, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-hook-done-quiet-window.test.ts": 28, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-hook-title-precedence.test.ts": 34, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-monitoring-turn-end.test.ts": 19, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts": 18, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts": 44, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-queued-inspection-disposal.test.ts": 24, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-stamped-turn-boundary.test.ts": 20, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-stamped-turn-replay.test.ts": 23, + "src/renderer/src/components/terminal-pane/agent-completion-no-evidence-cadence.test.ts": 47, + "src/renderer/src/components/terminal-pane/agent-completion-poll-interval.test.ts": 185, + "src/renderer/src/components/terminal-pane/agent-completion-stale-evidence-backoff.test.ts": 6, + "src/renderer/src/components/terminal-pane/agent-completion-steady-state-opt-in.test.ts": 11, + "src/renderer/src/components/terminal-pane/agent-hook-terminal-lifecycle.test.ts": 11, + "src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts": 30, + "src/renderer/src/components/terminal-pane/agent-process-inspection-queue-rejection-containment.test.ts": 14, + "src/renderer/src/components/terminal-pane/agent-process-inspection-round.test.ts": 18, + "src/renderer/src/components/terminal-pane/agent-question-answered-inference.test.ts": 14, + "src/renderer/src/components/terminal-pane/cache-timer-seeding.test.ts": 8, + "src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.test.ts": 112, + "src/renderer/src/components/terminal-pane/codex-backfill-error-detector.test.ts": 5, + "src/renderer/src/components/terminal-pane/codex-detached-pane-restart.test.ts": 305, + "src/renderer/src/components/terminal-pane/command-code-done-settle.test.ts": 18, + "src/renderer/src/components/terminal-pane/command-code-output-ownership.test.ts": 8, + "src/renderer/src/components/terminal-pane/compose-active-terminal-theme.test.ts": 7, + "src/renderer/src/components/terminal-pane/deferred-reattach-live-data-queue.test.ts": 173, + "src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts": 15, + "src/renderer/src/components/terminal-pane/desktop-fit-fallback.test.ts": 9, + "src/renderer/src/components/terminal-pane/direct-ssh-hidden-output-restore-unavailable-banner.test.ts": 1045, + "src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.test.ts": 31, + "src/renderer/src/components/terminal-pane/expand-collapse-render-stability.test.ts": 26, + "src/renderer/src/components/terminal-pane/expand-collapse.test.ts": 8, + "src/renderer/src/components/terminal-pane/focus-terminal-pane-event.test.ts": 10, + "src/renderer/src/components/terminal-pane/focused-pane-rim-flash.test.ts": 7, + "src/renderer/src/components/terminal-pane/force-park-buffer-capture.test.ts": 5, + "src/renderer/src/components/terminal-pane/git-bash-console-capacity.test.ts": 12, + "src/renderer/src/components/terminal-pane/hard-wrapped-terminal-http-links.test.ts": 34, + "src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.test.ts": 16, + "src/renderer/src/components/terminal-pane/hidden-reveal-reconciliation.fuzz.test.ts": 7500, + "src/renderer/src/components/terminal-pane/issue-12112-agent-pane-startup-color-reply-leak.repro.test.ts": 21, + "src/renderer/src/components/terminal-pane/issue-4631-terminal-hover-link-provider.repro.test.ts": 177, + "src/renderer/src/components/terminal-pane/keyboard-handlers-ime-composing-chord.test.tsx": 48, + "src/renderer/src/components/terminal-pane/keyboard-handlers-ime-enter-keyup.test.tsx": 59, + "src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx": 54, + "src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts": 17, + "src/renderer/src/components/terminal-pane/layout-serialization.test.ts": 19, + "src/renderer/src/components/terminal-pane/manual-terminal-worktree-park-eligibility.test.ts": 8, + "src/renderer/src/components/terminal-pane/merge-captured-leaf-state.test.ts": 16, + "src/renderer/src/components/terminal-pane/mobile-driver-overlay-collapse.test.ts": 7, + "src/renderer/src/components/terminal-pane/mobile-driver-overlay-focus.test.ts": 5, + "src/renderer/src/components/terminal-pane/mobile-driver-overlay-visibility.test.ts": 6, + "src/renderer/src/components/terminal-pane/mouse-hide-while-typing.test.ts": 6, + "src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.test.ts": 13, + "src/renderer/src/components/terminal-pane/osc52-clipboard-default-on-notice.test.ts": 58, + "src/renderer/src/components/terminal-pane/osc52-clipboard-toast.test.ts": 69, + "src/renderer/src/components/terminal-pane/osc52-clipboard.test.ts": 122, + "src/renderer/src/components/terminal-pane/override-affected-panes.test.ts": 7, + "src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts": 1727, + "src/renderer/src/components/terminal-pane/pane-agent-session-id.test.ts": 5, + "src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts": 59, + "src/renderer/src/components/terminal-pane/pane-foreground-inspect-observation-identity.test.ts": 10, + "src/renderer/src/components/terminal-pane/pane-helpers.test.ts": 9, + "src/renderer/src/components/terminal-pane/pane-title-overlay-rects.test.ts": 6, + "src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts": 2029, + "src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts": 56, + "src/renderer/src/components/terminal-pane/parse-osc7.test.ts": 5, + "src/renderer/src/components/terminal-pane/pty-buffer-serializer.test.ts": 19, + "src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.test.ts": 1727, + "src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts": 1162, + "src/renderer/src/components/terminal-pane/pty-connection-cold-restore-repaint.test.ts": 699, + "src/renderer/src/components/terminal-pane/pty-connection-cold-restore-resume-command.test.ts": 709, + "src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts": 1591, + "src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts": 3135, + "src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts": 1466, + "src/renderer/src/components/terminal-pane/pty-connection-deferred-ssh-passphrase.test.ts": 1225, + "src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.test.ts": 1131, + "src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts": 1727, + "src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts": 1570, + "src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts": 1216, + "src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts": 1659, + "src/renderer/src/components/terminal-pane/pty-connection-foreground-routing-confirmation.test.ts": 624, + "src/renderer/src/components/terminal-pane/pty-connection-foreground-write-path.test.ts": 1254, + "src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts": 1432, + "src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.test.ts": 1373, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-atlas-recovery.test.ts": 1032, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-reconciliation.test.ts": 939, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts": 3190, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts": 1467, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-delivery-gate.test.ts": 1677, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts": 597, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-query-snapshot-restore.test.ts": 1298, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-fit-overflow.test.ts": 589, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-live-overlap.test.ts": 814, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-resize-signals.test.ts": 1012, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-tui-snapshot-replay.test.ts": 835, + "src/renderer/src/components/terminal-pane/pty-connection-hook-completion-bell-arbitration.test.ts": 1536, + "src/renderer/src/components/terminal-pane/pty-connection-hook-completion-side-effects.test.ts": 1433, + "src/renderer/src/components/terminal-pane/pty-connection-hook-idle-arbitration.test.ts": 1039, + "src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.test.ts": 916, + "src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.test.ts": 922, + "src/renderer/src/components/terminal-pane/pty-connection-mode-2031-subscriptions.test.ts": 1134, + "src/renderer/src/components/terminal-pane/pty-connection-notification-settings-gating.test.ts": 869, + "src/renderer/src/components/terminal-pane/pty-connection-parked-ssh-snapshot.test.ts": 1744, + "src/renderer/src/components/terminal-pane/pty-connection-post-dispose-restore-termination.test.ts": 771, + "src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.test.ts": 3670, + "src/renderer/src/components/terminal-pane/pty-connection-queued-startup-consume.test.ts": 705, + "src/renderer/src/components/terminal-pane/pty-connection-reattach-binding.test.ts": 2385, + "src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts": 1987, + "src/renderer/src/components/terminal-pane/pty-connection-remote-runtime-attach.test.ts": 1224, + "src/renderer/src/components/terminal-pane/pty-connection-remote-snapshot-source-grid.test.ts": 807, + "src/renderer/src/components/terminal-pane/pty-connection-renderer-risk-repaint.test.ts": 1489, + "src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts": 1203, + "src/renderer/src/components/terminal-pane/pty-connection-restored-baseline-shortfall.test.ts": 835, + "src/renderer/src/components/terminal-pane/pty-connection-runtime-owner-spawn-routing.test.ts": 925, + "src/renderer/src/components/terminal-pane/pty-connection-session-liveness.test.ts": 1912, + "src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.test.ts": 1083, + "src/renderer/src/components/terminal-pane/pty-connection-sleeping-resume-banner.test.ts": 798, + "src/renderer/src/components/terminal-pane/pty-connection-spawn-left-pane-unbound.test.ts": 897, + "src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts": 47, + "src/renderer/src/components/terminal-pane/pty-connection-ssh-startup-draft-delivery.test.ts": 698, + "src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts": 3547, + "src/renderer/src/components/terminal-pane/pty-connection-startup-command-delivery.test.ts": 876, + "src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts": 1186, + "src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts": 4079, + "src/renderer/src/components/terminal-pane/pty-connection-typed-agent-identity.test.ts": 1778, + "src/renderer/src/components/terminal-pane/pty-connection-visibility-resume-size.test.ts": 1757, + "src/renderer/src/components/terminal-pane/pty-connection-visible-pane-output-pause-latch.test.ts": 860, + "src/renderer/src/components/terminal-pane/pty-connection-windows-cjk-repaint.test.ts": 1118, + "src/renderer/src/components/terminal-pane/pty-connection-windows-keyboard-reset.test.ts": 1660, + "src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-reattach-recovery.test.ts": 8, + "src/renderer/src/components/terminal-pane/pty-connection/foreground-output-budgets.test.ts": 4, + "src/renderer/src/components/terminal-pane/pty-connection/pane-pty-layout-binding.test.ts": 6, + "src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts": 7, + "src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-ssh-reconnect-model-paint.test.ts": 16, + "src/renderer/src/components/terminal-pane/pty-connection/reattach-success-settle.test.ts": 12, + "src/renderer/src/components/terminal-pane/pty-connection/ssh-session-gone-verdict.test.ts": 5, + "src/renderer/src/components/terminal-pane/pty-connection/terminal-tab-id.test.ts": 6, + "src/renderer/src/components/terminal-pane/pty-connection/unbound-pane-spawn-recovery.test.ts": 12, + "src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-interest.test.ts": 98, + "src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-resync.test.ts": 56, + "src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts": 317, + "src/renderer/src/components/terminal-pane/pty-dispatcher-push-reattach.test.ts": 89, + "src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts": 750, + "src/renderer/src/components/terminal-pane/pty-model-restore-channel.test.ts": 10, + "src/renderer/src/components/terminal-pane/pty-pre-handler-buffer-warn-eviction.test.ts": 17, + "src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts": 47, + "src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts": 50, + "src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.test.ts": 10, + "src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.test.ts": 10, + "src/renderer/src/components/terminal-pane/pty-shutdown-exit-deferral.test.ts": 68, + "src/renderer/src/components/terminal-pane/pty-shutdown-output-queue.test.ts": 1985, + "src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.test.ts": 161, + "src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts": 12, + "src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts": 14, + "src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts": 383, + "src/renderer/src/components/terminal-pane/pty-transport-detach-attach-handoff.test.ts": 238, + "src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts": 321, + "src/renderer/src/components/terminal-pane/pty-transport-handler-suspension.test.ts": 311, + "src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts": 759, + "src/renderer/src/components/terminal-pane/pty-transport-output-side-effects.test.ts": 310, + "src/renderer/src/components/terminal-pane/pty-transport-pi-coalesce.test.ts": 169, + "src/renderer/src/components/terminal-pane/pty-transport-pi-spinner.test.ts": 165, + "src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts": 298, + "src/renderer/src/components/terminal-pane/pty-transport-recycled-pty-incarnation.test.ts": 157, + "src/renderer/src/components/terminal-pane/pty-transport-spawn-errors.test.ts": 148, + "src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.test.ts": 22, + "src/renderer/src/components/terminal-pane/remote-desktop-viewport-claim.test.ts": 5, + "src/renderer/src/components/terminal-pane/remote-execution-host-pty.test.ts": 5, + "src/renderer/src/components/terminal-pane/remote-hidden-output-restore-outcomes.test.ts": 1241, + "src/renderer/src/components/terminal-pane/remote-hidden-output-restore-unavailable-banner.repro.test.ts": 1012, + "src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts": 10, + "src/renderer/src/components/terminal-pane/remote-runtime-connect-failure-recovery.test.ts": 1731, + "src/renderer/src/components/terminal-pane/remote-runtime-error-surface-dismissal.test.ts": 1369, + "src/renderer/src/components/terminal-pane/remote-runtime-outage-toast-flood-and-stuck-reconnect.test.ts": 2153, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts": 72, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-deadline-reattach.test.ts": 2204, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-latched-pane-retention.test.ts": 4200, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts": 2087, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts": 26, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts": 1389, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-source-grid.test.ts": 1605, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-activation-inventory-fallback.test.ts": 2053, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts": 3506, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts": 2653, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts": 2166, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-end-verdict.test.ts": 1292, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-expired-pane-recovery.test.ts": 2463, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts": 2411, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts": 5095, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts": 3404, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-fallback.test.ts": 2595, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-handle-resolution.test.ts": 2613, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pending-host-surface-attach.test.ts": 1727, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-snapshot-replay.test.ts": 1654, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-split-leaf-activation.test.ts": 2871, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stale-handle-recovery.test.ts": 2313, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-sticky-replacement-policy.test.ts": 2038, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts": 3273, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-web-mirror-recovery.test.ts": 2711, + "src/renderer/src/components/terminal-pane/remote-runtime-resubscribe-failure-recovery-routing.test.ts": 1695, + "src/renderer/src/components/terminal-pane/renderer-owned-agent-status-registry.test.ts": 8, + "src/renderer/src/components/terminal-pane/replay-guard.test.ts": 37, + "src/renderer/src/components/terminal-pane/replayed-scrollback-store-release.test.ts": 5, + "src/renderer/src/components/terminal-pane/repro-8299-shift-space-input-source.test.ts": 14, + "src/renderer/src/components/terminal-pane/repro-8832-url-next-line.test.ts": 19, + "src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts": 13, + "src/renderer/src/components/terminal-pane/restored-snapshot-coverage.test.ts": 5, + "src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.test.tsx": 64, + "src/renderer/src/components/terminal-pane/shell-ready-marker-scan.test.ts": 6, + "src/renderer/src/components/terminal-pane/shutdown-buffer-captures.test.ts": 9, + "src/renderer/src/components/terminal-pane/sleeping-record-park-exemption.test.ts": 7, + "src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts": 8, + "src/renderer/src/components/terminal-pane/ssh-pane-connect-gate.test.ts": 9, + "src/renderer/src/components/terminal-pane/ssh-reattach-model-restore.test.ts": 16, + "src/renderer/src/components/terminal-pane/ssh-reconnect-model-paint-gate.test.ts": 7, + "src/renderer/src/components/terminal-pane/stale-document-visibility.test.ts": 34, + "src/renderer/src/components/terminal-pane/terminal-agent-paste-bracketing.test.ts": 15, + "src/renderer/src/components/terminal-pane/terminal-agent-session-continuation.test.ts": 13, + "src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts": 152, + "src/renderer/src/components/terminal-pane/terminal-alternate-screen-parse.test.ts": 30, + "src/renderer/src/components/terminal-pane/terminal-appearance.test.ts": 28, + "src/renderer/src/components/terminal-pane/terminal-bracketed-paste.test.ts": 16, + "src/renderer/src/components/terminal-pane/terminal-capability-replies.test.ts": 66, + "src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.test.ts": 16, + "src/renderer/src/components/terminal-pane/terminal-cjk-cursor-cell-placement.test.ts": 68, + "src/renderer/src/components/terminal-pane/terminal-clipboard-event-paste.test.ts": 10, + "src/renderer/src/components/terminal-pane/terminal-clipboard-paste.test.ts": 12, + "src/renderer/src/components/terminal-pane/terminal-cold-park-exempt-flip.react185.test.tsx": 37, + "src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx": 64, + "src/renderer/src/components/terminal-pane/terminal-cold-park-recheck-deadlines.test.ts": 12, + "src/renderer/src/components/terminal-pane/terminal-cold-park-subscription-narrowing.react185.test.tsx": 28, + "src/renderer/src/components/terminal-pane/terminal-cold-park-tab-model-identity.react185.test.tsx": 63, + "src/renderer/src/components/terminal-pane/terminal-cold-park-timer-rearm.test.tsx": 69, + "src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx": 39, + "src/renderer/src/components/terminal-pane/terminal-cold-park-withheld-tabs.test.ts": 9, + "src/renderer/src/components/terminal-pane/terminal-command-lifecycle.test.ts": 8, + "src/renderer/src/components/terminal-pane/terminal-context-menu-dismiss.test.ts": 7, + "src/renderer/src/components/terminal-pane/terminal-copy-rejection-handling.test.ts": 11, + "src/renderer/src/components/terminal-pane/terminal-ctrl-arrow-conpty.test.ts": 10, + "src/renderer/src/components/terminal-pane/terminal-ctrl-enter.test.ts": 15, + "src/renderer/src/components/terminal-pane/terminal-cursor-appearance-precedence.test.ts": 88, + "src/renderer/src/components/terminal-pane/terminal-cursor-inactive-style.test.ts": 12, + "src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts": 16, + "src/renderer/src/components/terminal-pane/terminal-delivery-watchdog.test.ts": 50, + "src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts": 34, + "src/renderer/src/components/terminal-pane/terminal-drop-image-path.test.ts": 5, + "src/renderer/src/components/terminal-pane/terminal-drop-internal-handler.test.ts": 28, + "src/renderer/src/components/terminal-pane/terminal-drop-path-writer.test.ts": 21, + "src/renderer/src/components/terminal-pane/terminal-drop-runtime-owner.test.ts": 5, + "src/renderer/src/components/terminal-pane/terminal-drop-shell.test.ts": 6, + "src/renderer/src/components/terminal-pane/terminal-drop-upload-report.test.ts": 8, + "src/renderer/src/components/terminal-pane/terminal-drop-write-failure.test.ts": 6, + "src/renderer/src/components/terminal-pane/terminal-error-accumulation.test.ts": 15, + "src/renderer/src/components/terminal-pane/terminal-error-remote-closed-localization.test.ts": 6, + "src/renderer/src/components/terminal-pane/terminal-eviction-exempt-tabs.test.ts": 7, + "src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts": 17, + "src/renderer/src/components/terminal-pane/terminal-fit-restore.test.ts": 16, + "src/renderer/src/components/terminal-pane/terminal-freeze-report.test.ts": 27, + "src/renderer/src/components/terminal-pane/terminal-handle-copy.test.ts": 11, + "src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts": 36, + "src/renderer/src/components/terminal-pane/terminal-hidden-restore-scrollback.test.ts": 7, + "src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts": 16, + "src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts": 11, + "src/renderer/src/components/terminal-pane/terminal-http-link-activation.test.ts": 4, + "src/renderer/src/components/terminal-pane/terminal-http-link-source-owner.test.ts": 11, + "src/renderer/src/components/terminal-pane/terminal-http-url-extraction.test.ts": 8, + "src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts": 10, + "src/renderer/src/components/terminal-pane/terminal-ime-composer-placeholder-mask.test.ts": 687, + "src/renderer/src/components/terminal-pane/terminal-ime-composition-route.test.ts": 23, + "src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts": 16, + "src/renderer/src/components/terminal-pane/terminal-ime-composition-transaction-ownership.test.ts": 17, + "src/renderer/src/components/terminal-pane/terminal-ime-deferred-chord.test.ts": 10, + "src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.test.ts": 28, + "src/renderer/src/components/terminal-pane/terminal-ime-forwarder-space-claim.test.ts": 89, + "src/renderer/src/components/terminal-pane/terminal-ime-hangul-syllable-flush.test.ts": 261, + "src/renderer/src/components/terminal-pane/terminal-ime-hangul-terminating-digit.test.ts": 9, + "src/renderer/src/components/terminal-pane/terminal-ime-input-context-refresh.test.ts": 22, + "src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts": 14, + "src/renderer/src/components/terminal-pane/terminal-ime-macos-keybinding-dict-trace.test.ts": 134, + "src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts": 35, + "src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts": 160, + "src/renderer/src/components/terminal-pane/terminal-ime-won-composition-order.test.ts": 160, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-adversarial.test.ts": 1021, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-cancelled-preedit-visibility.test.ts": 152, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-composition-cancel.test.ts": 133, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-composition-deduplication.test.ts": 446, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-consumed-key-commit.test.ts": 157, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-korean-enter-commit-order.test.ts": 222, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-linux-native-trace-replay.test.ts": 2429, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-midline-preedit-tail.test.ts": 270, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-resumed-preedit-visibility.test.ts": 111, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-trailing-preedit-occlusion.test.ts": 206, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-transaction-events.test.ts": 277, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-windows-resumed-preedit-trace.test.ts": 273, + "src/renderer/src/components/terminal-pane/terminal-input-host-platform.test.ts": 10, + "src/renderer/src/components/terminal-pane/terminal-input-quarantine.test.ts": 13, + "src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-coexistence.test.ts": 295, + "src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-device-trace.test.ts": 302, + "src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit.test.ts": 353, + "src/renderer/src/components/terminal-pane/terminal-jis-yen-input.test.ts": 4, + "src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers-focus.test.ts": 17, + "src/renderer/src/components/terminal-pane/terminal-keyboard-pane-resolution.test.ts": 12, + "src/renderer/src/components/terminal-pane/terminal-keyboard-protocol-pane-agent.test.ts": 5, + "src/renderer/src/components/terminal-pane/terminal-kitty-csi-u-encoding.test.ts": 16, + "src/renderer/src/components/terminal-pane/terminal-layout-duplicate-pty-replay.test.ts": 12, + "src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.test.ts": 12, + "src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.test.ts": 11, + "src/renderer/src/components/terminal-pane/terminal-layout-overlay-focus.test.tsx": 27, + "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts": 67, + "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts": 26, + "src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts": 29, + "src/renderer/src/components/terminal-pane/terminal-link-activation.test.ts": 8, + "src/renderer/src/components/terminal-pane/terminal-link-click-fallback.test.ts": 66, + "src/renderer/src/components/terminal-pane/terminal-link-file-open-routing.test.ts": 52, + "src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts": 8, + "src/renderer/src/components/terminal-pane/terminal-link-osc-file-targets.test.ts": 64, + "src/renderer/src/components/terminal-pane/terminal-link-osc-url-routing.test.ts": 27, + "src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.test.ts": 11, + "src/renderer/src/components/terminal-pane/terminal-link-provider-link-detection.test.ts": 91, + "src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.test.ts": 35, + "src/renderer/src/components/terminal-pane/terminal-link-remote-runtime-ssh-open.test.ts": 32, + "src/renderer/src/components/terminal-pane/terminal-link-worktree-root-activation.test.ts": 40, + "src/renderer/src/components/terminal-pane/terminal-link-wsl-path-mapping.test.ts": 42, + "src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.test.ts": 10, + "src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.test.ts": 19, + "src/renderer/src/components/terminal-pane/terminal-non-latin-control-chord.test.ts": 6, + "src/renderer/src/components/terminal-pane/terminal-option-kitty-release.test.ts": 9, + "src/renderer/src/components/terminal-pane/terminal-output-visibility.test.ts": 7, + "src/renderer/src/components/terminal-pane/terminal-pane-attention-subscriptions.test.ts": 6, + "src/renderer/src/components/terminal-pane/terminal-pane-close-identity.test.ts": 4, + "src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts": 111, + "src/renderer/src/components/terminal-pane/terminal-pane-host-state-memo.test.ts": 21, + "src/renderer/src/components/terminal-pane/terminal-pane-host-state.test.ts": 13, + "src/renderer/src/components/terminal-pane/terminal-pane-listener-order-parity.test.ts": 196, + "src/renderer/src/components/terminal-pane/terminal-pane-recovery-unsettled-fallback.test.ts": 14, + "src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts": 44, + "src/renderer/src/components/terminal-pane/terminal-pane-split-completion.test.ts": 9, + "src/renderer/src/components/terminal-pane/terminal-pane-split-request-routing.test.ts": 20, + "src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts": 17, + "src/renderer/src/components/terminal-pane/terminal-pane-split-writer-paths.test.ts": 15, + "src/renderer/src/components/terminal-pane/terminal-pane-store-subscription-budget.test.tsx": 128, + "src/renderer/src/components/terminal-pane/terminal-pane-surface-ownership.test.ts": 23, + "src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts": 25, + "src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.test.ts": 19, + "src/renderer/src/components/terminal-pane/terminal-park-verdict-worktree-driver-loop.test.tsx": 45, + "src/renderer/src/components/terminal-pane/terminal-parked-pane-candidates.test.ts": 7, + "src/renderer/src/components/terminal-pane/terminal-parked-tab-eviction-exemption.test.ts": 14, + "src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers-batch-sync.test.ts": 92, + "src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts": 69, + "src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts": 8, + "src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts": 5, + "src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.test.ts": 6, + "src/renderer/src/components/terminal-pane/terminal-parked-watcher-sleep-preserved-exit.test.ts": 13, + "src/renderer/src/components/terminal-pane/terminal-parked-watcher-sole-newborn-exit.test.ts": 10, + "src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts": 2548, + "src/renderer/src/components/terminal-pane/terminal-parser-handler-guard.test.ts": 18, + "src/renderer/src/components/terminal-pane/terminal-paste-coordinator.test.ts": 39, + "src/renderer/src/components/terminal-pane/terminal-paste-executor-default-yield.test.ts": 7, + "src/renderer/src/components/terminal-pane/terminal-paste-multiline-policy.test.ts": 5, + "src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts": 30, + "src/renderer/src/components/terminal-pane/terminal-paste-payload-metadata.test.ts": 8, + "src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts": 8, + "src/renderer/src/components/terminal-pane/terminal-paste-target-state.test.ts": 9, + "src/renderer/src/components/terminal-pane/terminal-process-exit-restart.test.ts": 7, + "src/renderer/src/components/terminal-pane/terminal-programmatic-text-paste.test.ts": 84, + "src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts": 16, + "src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.test.ts": 5, + "src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.test.ts": 14, + "src/renderer/src/components/terminal-pane/terminal-remote-runtime-recovery-ui-state.test.ts": 5, + "src/renderer/src/components/terminal-pane/terminal-render-desync-frame.test.ts": 4, + "src/renderer/src/components/terminal-pane/terminal-render-desync-sentinel.test.ts": 395, + "src/renderer/src/components/terminal-pane/terminal-render-desync-weight-probe.test.ts": 9, + "src/renderer/src/components/terminal-pane/terminal-renderer-policy.test.ts": 11, + "src/renderer/src/components/terminal-pane/terminal-replay-application-continuity.test.ts": 266, + "src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts": 70, + "src/renderer/src/components/terminal-pane/terminal-restore-sgr-latch.test.ts": 62, + "src/renderer/src/components/terminal-pane/terminal-restored-viewport.test.ts": 25, + "src/renderer/src/components/terminal-pane/terminal-retention-exempt-growth.test.ts": 10, + "src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts": 14, + "src/renderer/src/components/terminal-pane/terminal-selection-copy.test.ts": 10, + "src/renderer/src/components/terminal-pane/terminal-shortcut-ctrl-arrow.test.ts": 13, + "src/renderer/src/components/terminal-pane/terminal-shortcut-option-compose.test.ts": 29, + "src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts": 27, + "src/renderer/src/components/terminal-pane/terminal-shortcut-select-all.test.ts": 8, + "src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts": 139, + "src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts": 17, + "src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts": 287, + "src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts": 7, + "src/renderer/src/components/terminal-pane/terminal-startup-grid-settle.test.ts": 14, + "src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.test.ts": 5, + "src/renderer/src/components/terminal-pane/terminal-tab-lookup.test.ts": 8, + "src/renderer/src/components/terminal-pane/terminal-title-evidence.test.ts": 12, + "src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts": 31, + "src/renderer/src/components/terminal-pane/terminal-unified-tab-lookup.test.ts": 16, + "src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts": 29, + "src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.test.ts": 14, + "src/renderer/src/components/terminal-pane/terminal-user-input-signal.test.ts": 15, + "src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.test.ts": 23, + "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts": 31, + "src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts": 8, + "src/renderer/src/components/terminal-pane/terminal-webgl-diagnostics-breadcrumbs.test.ts": 9, + "src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts": 19, + "src/renderer/src/components/terminal-pane/terminal-worktree-path-link.test.ts": 6, + "src/renderer/src/components/terminal-pane/title-agent-identity.test.ts": 6, + "src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.test.ts": 3, + "src/renderer/src/components/terminal-pane/use-mobile-overlay-ticks.perf.test.tsx": 31, + "src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts": 44, + "src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx": 59, + "src/renderer/src/components/terminal-pane/use-system-prefers-dark.test.ts": 7, + "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-active-pty-reporting.test.ts": 15, + "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-file-drop.test.ts": 22, + "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-paste-events.test.ts": 43, + "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-sync-fit-registration.test.ts": 14, + "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-visibility-resume.test.ts": 22, + "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-window-focus-recovery.test.ts": 28, + "src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts": 19, + "src/renderer/src/components/terminal-pane/use-terminal-park-mount-intent.react185.test.tsx": 26, + "src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts": 9, + "src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts": 99, + "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts": 55, + "src/renderer/src/components/terminal-pane/use-visible-terminal-tab-claim.test.ts": 5, + "src/renderer/src/components/terminal-pane/useSessionRestoredBannerDismiss.test.tsx": 29, + "src/renderer/src/components/terminal-pane/useTerminalFontZoom.test.ts": 10, + "src/renderer/src/components/terminal-pane/wrapped-terminal-link-ranges.test.ts": 25, + "src/renderer/src/components/terminal-pane/xterm-bypass-policy-interrupt.test.ts": 13, + "src/renderer/src/components/terminal-pane/xterm-bypass-policy-ios.test.ts": 14, + "src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts": 14, + "src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts": 9, + "src/renderer/src/components/terminal-pane/xterm-write-buffer-stall.repro.test.ts": 24, + "src/renderer/src/components/terminal-parked-watcher-sync-entries.test.tsx": 37, + "src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx": 660, + "src/renderer/src/components/terminal-quick-commands/terminal-quick-command-agent-options.test.ts": 10, + "src/renderer/src/components/terminal-quick-commands/terminal-quick-command-dialog-draft.test.ts": 11, + "src/renderer/src/components/terminal-scrollback-decoration-eviction.test.ts": 414, + "src/renderer/src/components/terminal-search-decoration-leak.test.ts": 1942, + "src/renderer/src/components/terminal-search-long-wrapped-line.test.ts": 4994, + "src/renderer/src/components/terminal-search-safe-find.test.ts": 7, + "src/renderer/src/components/terminal-workspace-keydown.test.ts": 246, + "src/renderer/src/components/terminal-workspace-surface-ids.test.tsx": 87, + "src/renderer/src/components/terminal/activation-deferred-tab-admission.test.ts": 13, + "src/renderer/src/components/terminal/active-terminal-repair-loop.react185.test.tsx": 38, + "src/renderer/src/components/terminal/active-terminal-repair.test.ts": 6, + "src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts": 24, + "src/renderer/src/components/terminal/background-terminal-worktree-visibility.test.ts": 14, + "src/renderer/src/components/terminal/cold-activation-deferral-stranding.test.ts": 37, + "src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx": 26, + "src/renderer/src/components/terminal/initial-terminal-wiring.test.ts": 4, + "src/renderer/src/components/terminal/initial-terminal.test.ts": 4, + "src/renderer/src/components/terminal/pty-running-work-probe-child-evidence.test.ts": 9, + "src/renderer/src/components/terminal/running-terminal-close-guard.test.ts": 18, + "src/renderer/src/components/terminal/split-group-mount.test.ts": 8, + "src/renderer/src/components/terminal/tab-type-cycle.test.ts": 9, + "src/renderer/src/components/terminal/terminal-close-confirm-keyboard-vs-mouse.test.ts": 12, + "src/renderer/src/components/terminal/terminal-close-copy-kind.test.ts": 8, + "src/renderer/src/components/terminal/terminal-close-incarnation.test.ts": 5, + "src/renderer/src/components/terminal/terminal-provider-snapshot-bound-pty-ids.test.ts": 74, + "src/renderer/src/components/terminal/terminal-provider-snapshot-capability-resettlement.test.ts": 15, + "src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts": 15, + "src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts": 15, + "src/renderer/src/components/terminal/terminal-tab-actions-structured-session.test.ts": 17, + "src/renderer/src/components/terminal/terminal-tab-actions-unified-close.test.ts": 32, + "src/renderer/src/components/terminal/terminal-tab-actions.test.ts": 32, + "src/renderer/src/components/terminal/terminal-tab-bulk-actions.test.ts": 12, + "src/renderer/src/components/terminal/terminal-tab-close-running-confirm.test.ts": 21, + "src/renderer/src/components/terminal/unsaved-close-queue.test.ts": 5, + "src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.test.tsx": 50, + "src/renderer/src/components/terminal/use-worktree-files.test.tsx": 52, + "src/renderer/src/components/terminal/window-close-running-work.test.ts": 23, + "src/renderer/src/components/ui/color-picker.test.tsx": 24, + "src/renderer/src/components/ui/popover-wheel-scroll.test.tsx": 243, + "src/renderer/src/components/ui/repo-multi-combobox.test.ts": 6, + "src/renderer/src/components/ui/scroll-area.test.tsx": 39, + "src/renderer/src/components/ui/slider.test.tsx": 111, + "src/renderer/src/components/ui/switch.test.tsx": 29, + "src/renderer/src/components/unexpected-signout/unexpected-signout-card.test.tsx": 206, + "src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.test.ts": 9, + "src/renderer/src/components/use-github-task-search-commit.test.ts": 35, + "src/renderer/src/components/use-task-creation-draft-retention.test.ts": 25, + "src/renderer/src/components/use-terminal-create-actions.test.tsx": 22, + "src/renderer/src/components/use-terminal-editor-close-foundation.window-close.test.tsx": 28, + "src/renderer/src/components/use-terminal-window-lifecycle.lazy-ref.test.tsx": 16, + "src/renderer/src/components/use-worktree-jump-palette-browser-ownership.test.ts": 37, + "src/renderer/src/components/window-close-request-coordinator.test.ts": 19, + "src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.mount-gating.test.tsx": 100, + "src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.stale-while-revalidate.test.tsx": 1307, + "src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-browse-state.test.tsx": 43, + "src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.test.tsx": 58, + "src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.test.tsx": 60, + "src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-row-order.test.ts": 26, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.test.ts": 11, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-late-settlement.test.ts": 15, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-snapshot-batch.test.ts": 63, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts": 31, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.test.tsx": 47, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts": 6, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx": 214, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx": 118, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-deletion-phases.test.ts": 9, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.test.tsx": 59, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.test.tsx": 357, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-filter.test.ts": 39, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.test.ts": 12, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.test.tsx": 237, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx": 280, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.test.ts": 16, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.test.ts": 27, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.test.ts": 7, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-settlement.test.ts": 9, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-timeout-recovery.test.ts": 26, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-scanned-host-confirmation-removal.test.tsx": 470, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.test.tsx": 82, + "src/renderer/src/components/workspace-emoji/WorkspaceEmojiSuggestionPopover.test.tsx": 83, + "src/renderer/src/components/workspace-emoji/useWorkspaceEmojiShortcodeInput.test.tsx": 122, + "src/renderer/src/components/workspace-surface-projection.test.ts": 39, + "src/renderer/src/components/worktree-creation/WorktreeCreationPanel.test.tsx": 74, + "src/renderer/src/components/worktree-jump-palette-interleaved-sections.test.tsx": 1645, + "src/renderer/src/components/worktree-jump-palette-primitives.test.tsx": 131, + "src/renderer/src/components/worktree-jump-palette-quick-action-availability.test.tsx": 20, + "src/renderer/src/components/worktree-jump-palette-sleeping-filter.test.ts": 9, + "src/renderer/src/components/worktree-jump-palette-source-context-boundary.test.ts": 4, + "src/renderer/src/components/worktree-jump-palette-status-inputs.test.ts": 6, + "src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts": 134, + "src/renderer/src/hooks/agent-hook-completion-fresh-working.test.ts": 109, + "src/renderer/src/hooks/agent-hook-completion-notifications-import.test.ts": 92, + "src/renderer/src/hooks/agent-hook-completion-notifications.test.ts": 264, + "src/renderer/src/hooks/agent-hook-completion-store-sync.test.ts": 18, + "src/renderer/src/hooks/automation-agent-status-entry-change.test.ts": 7, + "src/renderer/src/hooks/automation-dispatch-unverifiable-loss.test.ts": 137, + "src/renderer/src/hooks/automations-changed-event-attribution.test.ts": 1424, + "src/renderer/src/hooks/composer-branch-selection.test.ts": 11, + "src/renderer/src/hooks/composer-drop-owner.test.ts": 4, + "src/renderer/src/hooks/composer-drop-upload-result.test.ts": 8, + "src/renderer/src/hooks/composer-native-file-drop.test.ts": 9, + "src/renderer/src/hooks/composer-state/composer-drop-listener.test.ts": 26, + "src/renderer/src/hooks/composer-state/draft-target-sync.test.ts": 24, + "src/renderer/src/hooks/composer-state/full-creation-execution.test.ts": 23, + "src/renderer/src/hooks/composer-state/full-creation-structured-launch.test.ts": 7, + "src/renderer/src/hooks/composer-state/full-submit-orchestration.test.ts": 22, + "src/renderer/src/hooks/composer-state/multiple-create-reset.test.ts": 29, + "src/renderer/src/hooks/composer-state/provider-runtime-sync.test.ts": 18, + "src/renderer/src/hooks/composer-state/quick-creation-request.test.ts": 10, + "src/renderer/src/hooks/direct-ssh-host-hydration.test.ts": 24, + "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts": 30, + "src/renderer/src/hooks/direct-ssh-reconnect-rollout.test.ts": 11, + "src/renderer/src/hooks/direct-ssh-reconnect-tokens.test.ts": 6, + "src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx": 43, + "src/renderer/src/hooks/direct-ssh-state-routing.test.ts": 16, + "src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts": 27, + "src/renderer/src/hooks/fork-push-warning.test.ts": 5, + "src/renderer/src/hooks/installed-agent-skill-discovery-cache.test.ts": 18, + "src/renderer/src/hooks/installed-agent-skill-discovery.test.ts": 9, + "src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.test.ts": 79, + "src/renderer/src/hooks/ipc-events/agent-status-pending-retry-gate.test.ts": 272, + "src/renderer/src/hooks/ipc-events/agent-status-terminal-title-tab-write.test.ts": 186, + "src/renderer/src/hooks/ipc-events/browser-automation-bootstrap-lease.test.ts": 10, + "src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.profile-switch.test.ts": 7, + "src/renderer/src/hooks/ipc-events/browser-state-open-link-profile.test.ts": 8, + "src/renderer/src/hooks/ipc-events/direct-ssh-hydration-fanout.test.ts": 12, + "src/renderer/src/hooks/ipc-events/direct-ssh-hydration-target-metadata.test.ts": 21, + "src/renderer/src/hooks/ipc-events/normalize-agent-status-event.test.ts": 6, + "src/renderer/src/hooks/ipc-events/orca-profile-auth-ipc-bridge.test.ts": 111, + "src/renderer/src/hooks/ipc-events/os-markdown-file-open-bridge.test.ts": 16, + "src/renderer/src/hooks/ipc-events/runtime-reconnect-host-status.test.ts": 32, + "src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge-split.test.ts": 15, + "src/renderer/src/hooks/ipc-events/updater-status-ipc-bridge.test.ts": 6, + "src/renderer/src/hooks/ipc-tab-switch-group-order-hydration.test.ts": 11, + "src/renderer/src/hooks/ipc-tab-switch-structured-tab-cycle.test.ts": 11, + "src/renderer/src/hooks/ipc-tab-switch.test.ts": 17, + "src/renderer/src/hooks/legacy-worker-terminal-recovery-event.test.ts": 15, + "src/renderer/src/hooks/macos-tcc-prompt-notice-subscription.test.ts": 23, + "src/renderer/src/hooks/metadata-request-cache.test.ts": 142, + "src/renderer/src/hooks/mobile-terminal-reveal-tab-adoption.test.ts": 2166, + "src/renderer/src/hooks/modal-return-focus-action.test.ts": 7, + "src/renderer/src/hooks/programmatic-scroll-marks.test.ts": 9, + "src/renderer/src/hooks/remote-workspace-deferred-placement-retry.test.ts": 14, + "src/renderer/src/hooks/remote-workspace-session-merge-close-tombstones.test.ts": 13, + "src/renderer/src/hooks/remote-workspace-session-merge-closed-terminal-tombstone.test.ts": 39, + "src/renderer/src/hooks/remote-workspace-session-merge-local-survival.test.ts": 12, + "src/renderer/src/hooks/remote-workspace-snapshot-apply-deferred-session-write.test.ts": 76, + "src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts": 8, + "src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts": 68, + "src/renderer/src/hooks/remote-workspace-snapshot-fresh-client-tab-seeding.test.ts": 19, + "src/renderer/src/hooks/remote-workspace-snapshot-local-tab-survival.test.ts": 108, + "src/renderer/src/hooks/remote-workspace-snapshot-unplaced-tab-adoption.test.ts": 80, + "src/renderer/src/hooks/remote-workspace-target-sync.test.ts": 45, + "src/renderer/src/hooks/runtime-client-events-sync.test.ts": 66, + "src/renderer/src/hooks/runtime-project-refresh-scheduler.test.ts": 51, + "src/renderer/src/hooks/shortcut-label-cache.test.tsx": 65, + "src/renderer/src/hooks/ssh-reconnect-pane-retry.test.ts": 7, + "src/renderer/src/hooks/structured-session-completion-focus.test.ts": 1364, + "src/renderer/src/hooks/unpaired-device-auth-notification.test.ts": 6, + "src/renderer/src/hooks/use-audio-capture.capture-loss.test.ts": 31, + "src/renderer/src/hooks/use-clipboard-text-copy-feedback.test.ts": 28, + "src/renderer/src/hooks/use-now.test.ts": 35, + "src/renderer/src/hooks/use-palette-search-evaluation-context.test.ts": 29, + "src/renderer/src/hooks/use-task-page-github-work-item-mutation-host.test.ts": 7, + "src/renderer/src/hooks/use-terminal-quick-command-hosts.test.ts": 59, + "src/renderer/src/hooks/use-window-stream-visibility.test.ts": 37, + "src/renderer/src/hooks/useActiveProjectSkillRuntime.test.tsx": 57, + "src/renderer/src/hooks/useAgentDetectionTarget.test.ts": 11, + "src/renderer/src/hooks/useAppMenuPaste.test.tsx": 29, + "src/renderer/src/hooks/useAppMenuSelectionActions.test.tsx": 13, + "src/renderer/src/hooks/useAutoAckViewedAgent.away.test.ts": 95, + "src/renderer/src/hooks/useAutoAckViewedAgent.clock-skew.test.ts": 55, + "src/renderer/src/hooks/useAutoAckViewedAgent.floating-panel.test.ts": 38, + "src/renderer/src/hooks/useAutoAckViewedAgent.test.ts": 63, + "src/renderer/src/hooks/useAutomationDispatchEvents.test.ts": 692, + "src/renderer/src/hooks/useComposerState-decisions.test.ts": 9, + "src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts": 21, + "src/renderer/src/hooks/useComposerState-host-retarget.test.ts": 7, + "src/renderer/src/hooks/useComposerState.integration.test.ts": 39, + "src/renderer/src/hooks/useDetectedAgents.test.tsx": 43, + "src/renderer/src/hooks/useEditorExternalWatch-complexity.test.ts": 6402, + "src/renderer/src/hooks/useEditorExternalWatch-self-move.test.ts": 27, + "src/renderer/src/hooks/useEditorExternalWatch-subscriptions.test.tsx": 24, + "src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts": 21, + "src/renderer/src/hooks/useEditorExternalWatch-wsl-repro.test.ts": 21, + "src/renderer/src/hooks/useEditorExternalWatch.test.ts": 54, + "src/renderer/src/hooks/useEphemeralVmRecipeOptions.test.tsx": 33, + "src/renderer/src/hooks/useGitHubSlugMetadata.test.tsx": 47, + "src/renderer/src/hooks/useGlobalFileDrop.test.ts": 15, + "src/renderer/src/hooks/useInstalledAgentSkills.react.test.tsx": 90, + "src/renderer/src/hooks/useInstalledAgentSkills.test.ts": 17, + "src/renderer/src/hooks/useIpcEvents-agent-dashboard-shortcut.test.ts": 9, + "src/renderer/src/hooks/useIpcEvents-agent-status-batch-projection.test.ts": 1128, + "src/renderer/src/hooks/useIpcEvents-agent-status-connection-attribution.test.ts": 1955, + "src/renderer/src/hooks/useIpcEvents-agent-status-hook-titles.test.ts": 1349, + "src/renderer/src/hooks/useIpcEvents-agent-status-pane-teardown.test.ts": 2111, + "src/renderer/src/hooks/useIpcEvents-agent-status-queue-ordering.test.ts": 1997, + "src/renderer/src/hooks/useIpcEvents-agent-status-snapshot-hydration.test.ts": 2101, + "src/renderer/src/hooks/useIpcEvents-agent-status-snapshot-replay.test.ts": 1690, + "src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts": 3444, + "src/renderer/src/hooks/useIpcEvents-agent-status-turn-completion.test.ts": 1433, + "src/renderer/src/hooks/useIpcEvents-browser-certificate-failure.test.ts": 1719, + "src/renderer/src/hooks/useIpcEvents-browser-navigation.test.ts": 1370, + "src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts": 1568, + "src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts": 1650, + "src/renderer/src/hooks/useIpcEvents-client-hosted-browser-rows.test.ts": 1604, + "src/renderer/src/hooks/useIpcEvents-close-routing-active-browser-tab.test.ts": 1787, + "src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts": 2510, + "src/renderer/src/hooks/useIpcEvents-close-routing-floating-guest.test.ts": 1438, + "src/renderer/src/hooks/useIpcEvents-close-routing-session-tabs.test.ts": 2721, + "src/renderer/src/hooks/useIpcEvents-cmd-j-digit-chord.test.ts": 1311, + "src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts": 1102, + "src/renderer/src/hooks/useIpcEvents-new-workspace-shortcut.test.ts": 12, + "src/renderer/src/hooks/useIpcEvents-rate-limit-hydration.test.ts": 1455, + "src/renderer/src/hooks/useIpcEvents-repos-changed-catalogs.test.ts": 1275, + "src/renderer/src/hooks/useIpcEvents-runtime-environment-selectors.test.ts": 17, + "src/renderer/src/hooks/useIpcEvents-session-tab-close-request.test.ts": 1943, + "src/renderer/src/hooks/useIpcEvents-silent-terminal-adoption.test.ts": 1945, + "src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts": 1453, + "src/renderer/src/hooks/useIpcEvents-terminal-create-surfacing.test.ts": 2086, + "src/renderer/src/hooks/useIpcEvents-updater-status.test.ts": 1582, + "src/renderer/src/hooks/useIpcEvents-zoom-routing.test.ts": 1559, + "src/renderer/src/hooks/useIssueMetadata.test.tsx": 27, + "src/renderer/src/hooks/useLinearProviderConnected.test.tsx": 19, + "src/renderer/src/hooks/useMacTccAttributionSeveredNotice.test.tsx": 320, + "src/renderer/src/hooks/useMacosTccPromptNotice.test.tsx": 207, + "src/renderer/src/hooks/useMetadataListRequest.test.tsx": 20, + "src/renderer/src/hooks/useModalReturnFocus.test.tsx": 64, + "src/renderer/src/hooks/usePrimarySelectionPaste.terminal-native-paste.test.tsx": 32, + "src/renderer/src/hooks/usePrimarySelectionPaste.test.tsx": 55, + "src/renderer/src/hooks/useResetCountdownClock.test.ts": 23, + "src/renderer/src/hooks/useRetiredWorktreeNames.test.ts": 344, + "src/renderer/src/hooks/useSettingsNavigationMetadata.capability-owner.test.tsx": 85, + "src/renderer/src/hooks/useSettingsNavigationMetadata.language-switch.test.tsx": 235, + "src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts": 184, + "src/renderer/src/hooks/useSidebarResize.test.ts": 9, + "src/renderer/src/hooks/useSkillFreshness.test.tsx": 65, + "src/renderer/src/hooks/useUnreadDockBadge.test.ts": 128, + "src/renderer/src/hooks/useVirtualizedScrollAnchor.listener-deps.test.ts": 30, + "src/renderer/src/hooks/useVirtualizedScrollAnchor.marks-restore-signal.test.ts": 31, + "src/renderer/src/hooks/useVirtualizedScrollAnchor.test.ts": 8, + "src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.test.tsx": 46, + "src/renderer/src/hooks/viewport-size-change-listener.test.ts": 6, + "src/renderer/src/hooks/worktree-change-refresh-queue.test.ts": 14, + "src/renderer/src/hooks/worktree-head-identity-apply.test.ts": 10, + "src/renderer/src/i18n/I18nProvider.test.tsx": 36, + "src/renderer/src/i18n/integration-card-status-localization.test.ts": 7, + "src/renderer/src/i18n/intl-locale.test.ts": 121, + "src/renderer/src/i18n/ja-technical-literal-mistranslations.test.ts": 7, + "src/renderer/src/i18n/ko-ui-semantic-mistranslations.test.ts": 9, + "src/renderer/src/i18n/lazy-locale.test.ts": 109, + "src/renderer/src/i18n/locale-english-regression.test.ts": 22, + "src/renderer/src/i18n/native-chat-locales.test.ts": 10, + "src/renderer/src/i18n/no-top-level-translate.test.ts": 2294, + "src/renderer/src/i18n/plugin-chrome-allowlist.test.ts": 11, + "src/renderer/src/i18n/pseudo-localization.test.ts": 8, + "src/renderer/src/i18n/relative-time-format.test.ts": 27, + "src/renderer/src/i18n/runtime-required-catalog.test.ts": 335, + "src/renderer/src/i18n/settings-status-label-localization.test.ts": 9, + "src/renderer/src/i18n/smart-workspace-jira-locales.test.ts": 8, + "src/renderer/src/i18n/technical-literal-catalog-values.test.ts": 8, + "src/renderer/src/i18n/weekday-names.test.ts": 40, + "src/renderer/src/i18n/worktree-visibility-locales.test.ts": 8, + "src/renderer/src/i18n/zh-technical-literal-mistranslations.test.ts": 45, + "src/renderer/src/lazy-modal-mount-state.test.ts": 5, + "src/renderer/src/lazy-use-ref-ratchet.test.ts": 165, + "src/renderer/src/lib/activate-ai-vault-structured-session-reveal.test.ts": 11, + "src/renderer/src/lib/activate-ai-vault-structured-session.test.ts": 17, + "src/renderer/src/lib/activate-tab-and-focus-pane.test.ts": 11, + "src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts": 385, + "src/renderer/src/lib/active-agent-note-send-focused-session.test.ts": 74, + "src/renderer/src/lib/active-agent-note-send-runtime-error-codes.test.ts": 6, + "src/renderer/src/lib/active-agent-note-send-target-detection.test.ts": 13, + "src/renderer/src/lib/active-view-persist.test.ts": 4, + "src/renderer/src/lib/activity-thread-display.test.ts": 10, + "src/renderer/src/lib/agent-background-session-launch-host.test.ts": 12, + "src/renderer/src/lib/agent-catalog-links.test.ts": 5, + "src/renderer/src/lib/agent-draft-readiness.test.ts": 8, + "src/renderer/src/lib/agent-followup-delivery.test.ts": 18659, + "src/renderer/src/lib/agent-hibernation-confirmation.test.ts": 4, + "src/renderer/src/lib/agent-hibernation-coordinator.test.ts": 163, + "src/renderer/src/lib/agent-hibernation-output-activity.test.ts": 6, + "src/renderer/src/lib/agent-hibernation-pane-age.test.ts": 9, + "src/renderer/src/lib/agent-hibernation-planner.test.ts": 30, + "src/renderer/src/lib/agent-hibernation-runtime-liveness-race.test.ts": 65, + "src/renderer/src/lib/agent-hibernation-visibility.test.ts": 5, + "src/renderer/src/lib/agent-launch-prompt-delivery.test.ts": 14, + "src/renderer/src/lib/agent-launch-route-connection-fallback.test.ts": 6, + "src/renderer/src/lib/agent-launch-route-input.test.ts": 16, + "src/renderer/src/lib/agent-launch-routing-caller-census.test.ts": 552, + "src/renderer/src/lib/agent-launch-routing.test.ts": 11, + "src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts": 13, + "src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts": 48, + "src/renderer/src/lib/agent-paste-draft.test.ts": 178, + "src/renderer/src/lib/agent-picker-search.test.ts": 28, + "src/renderer/src/lib/agent-ready-wait.test.ts": 7, + "src/renderer/src/lib/agent-resume-launch-target.test.ts": 2472, + "src/renderer/src/lib/agent-row-primary-text.test.ts": 8, + "src/renderer/src/lib/agent-row-tool-preview.test.ts": 9, + "src/renderer/src/lib/agent-send-title-status.test.ts": 14, + "src/renderer/src/lib/agent-session-fork-context.test.ts": 47, + "src/renderer/src/lib/agent-session-launch-plan.test.ts": 10, + "src/renderer/src/lib/agent-skill-cli-prerequisite.test.ts": 68, + "src/renderer/src/lib/agent-skill-nav-install-status.test.ts": 5, + "src/renderer/src/lib/agent-startup-delayed-delivery-perf.test.ts": 85, + "src/renderer/src/lib/agent-status-connection-ownership.test.ts": 7, + "src/renderer/src/lib/agent-status-count.test.ts": 14, + "src/renderer/src/lib/agent-status-epoch-clock.test.ts": 12, + "src/renderer/src/lib/agent-status-evidence-clock.test.ts": 6, + "src/renderer/src/lib/agent-status-terminal-title.test.ts": 11, + "src/renderer/src/lib/agent-status-worktree-attribution.test.ts": 6, + "src/renderer/src/lib/agent-status.test.ts": 31, + "src/renderer/src/lib/agent-tab-shortcuts.test.ts": 7, + "src/renderer/src/lib/agent-trust-preflight.test.ts": 6, + "src/renderer/src/lib/ai-vault-omp-cold-resume.test.ts": 19, + "src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts": 6, + "src/renderer/src/lib/ai-vault-resume-command.resumable-agent.test.ts": 9, + "src/renderer/src/lib/ai-vault-resume-command.test.ts": 31, + "src/renderer/src/lib/ai-vault-resume-shell.test.ts": 5, + "src/renderer/src/lib/ai-vault-resume-target.test.ts": 15, + "src/renderer/src/lib/ai-vault-session-drag.test.ts": 15, + "src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts": 16, + "src/renderer/src/lib/ai-vault-tab-title-sync-inputs.test.ts": 38, + "src/renderer/src/lib/ai-vault-tab-title-sync.test.ts": 576, + "src/renderer/src/lib/app-command-dispatch.test.ts": 6, + "src/renderer/src/lib/app-font-family.test.ts": 4, + "src/renderer/src/lib/app-menu-paste.test.ts": 49, + "src/renderer/src/lib/app-menu-selection-actions.test.ts": 4, + "src/renderer/src/lib/automation-session-observer.test.ts": 58, + "src/renderer/src/lib/automation-session-reuse.test.ts": 7, + "src/renderer/src/lib/automation-terminal-ownership.test.ts": 9, + "src/renderer/src/lib/browser-cookie-import-toast.test.ts": 18, + "src/renderer/src/lib/browser-history-match.performance.test.ts": 17, + "src/renderer/src/lib/browser-history-match.test.ts": 34, + "src/renderer/src/lib/browser-page-conversion-history.test.ts": 8, + "src/renderer/src/lib/browser-page-palette-activation.test.ts": 59, + "src/renderer/src/lib/browser-palette-page-entries.test.ts": 145, + "src/renderer/src/lib/browser-palette-search.test.ts": 28, + "src/renderer/src/lib/browser-uuid.test.ts": 5, + "src/renderer/src/lib/browser-workspace-tab-activation.test.ts": 9, + "src/renderer/src/lib/client-creation-action-policy.test.ts": 12, + "src/renderer/src/lib/cmd-j-github-url-lookup.test.ts": 8, + "src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts": 30, + "src/renderer/src/lib/cmd-j-linear-issue-intent.test.ts": 7, + "src/renderer/src/lib/cmd-j-section-leadership.test.ts": 9, + "src/renderer/src/lib/codex-account-display-label.test.ts": 6, + "src/renderer/src/lib/codex-pane-restart-eligibility.test.ts": 8, + "src/renderer/src/lib/codex-pane-selection-lane.test.ts": 21, + "src/renderer/src/lib/codex-session-restart-route-recheck.test.ts": 13, + "src/renderer/src/lib/codex-session-restart-shell-flap.test.ts": 30, + "src/renderer/src/lib/codex-session-restart.test.ts": 93, + "src/renderer/src/lib/codex-stale-pane-account-identity.test.ts": 17, + "src/renderer/src/lib/codex-stale-pane-sweep.test.ts": 44, + "src/renderer/src/lib/comment-body-line-count.test.ts": 40, + "src/renderer/src/lib/comment-body-submit-state.test.ts": 31, + "src/renderer/src/lib/composer-issue-command.test.ts": 7, + "src/renderer/src/lib/composer-submit-cancellation.test.ts": 7, + "src/renderer/src/lib/connection-context.test.ts": 56, + "src/renderer/src/lib/crash-diagnostics.test.ts": 83, + "src/renderer/src/lib/create-untitled-markdown.test.ts": 13, + "src/renderer/src/lib/desktop-window-chrome.test.ts": 5, + "src/renderer/src/lib/diff-comment-compat.test.ts": 5, + "src/renderer/src/lib/diff-comments-format.test.ts": 7, + "src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts": 11, + "src/renderer/src/lib/direct-ssh-target-scope.test.ts": 12, + "src/renderer/src/lib/doc-preview-grants.test.ts": 118, + "src/renderer/src/lib/document-theme.test.ts": 10, + "src/renderer/src/lib/editable-target.test.ts": 6, + "src/renderer/src/lib/editor-file-operation-owner.test.ts": 29, + "src/renderer/src/lib/editor-font-zoom.test.ts": 5, + "src/renderer/src/lib/ensure-hooks-confirmed.test.ts": 433, + "src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts": 162, + "src/renderer/src/lib/ensure-simulator-tab.test.ts": 5, + "src/renderer/src/lib/ephemeral-vm-failed-create-cleanup.test.ts": 7, + "src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts": 16, + "src/renderer/src/lib/ephemeral-vm-workspace-target.integration.test.ts": 8, + "src/renderer/src/lib/ephemeral-vm-workspace-target.test.ts": 13, + "src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts": 8, + "src/renderer/src/lib/execute-open-editor-path-move.test.ts": 209, + "src/renderer/src/lib/external-editor-open-capability.test.ts": 5, + "src/renderer/src/lib/feature-education-telemetry.test.ts": 12, + "src/renderer/src/lib/feedback-image-attachments.test.ts": 28, + "src/renderer/src/lib/file-preview-action-visibility.test.tsx": 22, + "src/renderer/src/lib/file-preview.test.ts": 16, + "src/renderer/src/lib/file-search-result-owner.test.ts": 6, + "src/renderer/src/lib/file-search-selection.test.ts": 8, + "src/renderer/src/lib/file-type-icons.test.ts": 9, + "src/renderer/src/lib/find-query-bounds.test.ts": 4, + "src/renderer/src/lib/finished-agent-resume-resurrection.test.ts": 38, + "src/renderer/src/lib/fix-checks-agent-launch.test.ts": 262, + "src/renderer/src/lib/flatten-retained-slice.test.ts": 132, + "src/renderer/src/lib/floating-terminal.test.ts": 7, + "src/renderer/src/lib/floating-workspace-terminal-actions.test.ts": 26, + "src/renderer/src/lib/floating-workspace-tour-interaction-snapshot.test.ts": 9, + "src/renderer/src/lib/focus-terminal-tab-surface.test.ts": 16, + "src/renderer/src/lib/folder-workspace-path-status.test.ts": 8, + "src/renderer/src/lib/foreground-terminal-tabs.test.ts": 14, + "src/renderer/src/lib/github-links.test.ts": 11, + "src/renderer/src/lib/github-pr-start-point.test.ts": 11, + "src/renderer/src/lib/github-source-runtime-context.test.ts": 8, + "src/renderer/src/lib/github-work-item-source-lookup.test.ts": 8, + "src/renderer/src/lib/github-work-item-workspace-attachment.test.ts": 10, + "src/renderer/src/lib/gitlab-links.test.ts": 14, + "src/renderer/src/lib/gitlab-work-item-source-lookup.test.ts": 8, + "src/renderer/src/lib/hook-command-delayed-delivery-perf.test.ts": 121, + "src/renderer/src/lib/hook-command-delayed-delivery.test.ts": 11, + "src/renderer/src/lib/host-mirrored-pane-resume-replay.test.ts": 20, + "src/renderer/src/lib/http-link-destinations.test.ts": 14, + "src/renderer/src/lib/http-link-modifier-routing.test.ts": 12, + "src/renderer/src/lib/http-link-routing.test.ts": 25, + "src/renderer/src/lib/i18n-jsx-spacing-guard.test.ts": 23, + "src/renderer/src/lib/ime-composition-keyboard-event.test.ts": 30, + "src/renderer/src/lib/ios-web-platform.test.ts": 6, + "src/renderer/src/lib/jira-source-host.test.ts": 8, + "src/renderer/src/lib/keyboard-layout/detect-option-as-alt.test.ts": 11, + "src/renderer/src/lib/keyboard-layout/input-source-id.test.ts": 4, + "src/renderer/src/lib/keyboard-layout/layout-base-character.test.ts": 14, + "src/renderer/src/lib/keyboard-layout/option-as-alt-probe.test.ts": 20, + "src/renderer/src/lib/keyboard-layout/option-key-location-state.test.ts": 7, + "src/renderer/src/lib/language-detect.test.ts": 8, + "src/renderer/src/lib/large-text-control-paste.test.ts": 23, + "src/renderer/src/lib/launch-agent-background-session-remote.test.ts": 498, + "src/renderer/src/lib/launch-agent-background-session.test.ts": 360, + "src/renderer/src/lib/launch-agent-in-new-tab-cwd.test.ts": 653, + "src/renderer/src/lib/launch-agent-in-new-tab-host-resolution.test.ts": 553, + "src/renderer/src/lib/launch-agent-in-new-tab-structured.test.ts": 15, + "src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts": 544, + "src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts": 760, + "src/renderer/src/lib/launch-agent-in-new-tab.test.ts": 1687, + "src/renderer/src/lib/launch-agent-session-continuation.test.ts": 27, + "src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts": 945, + "src/renderer/src/lib/launch-ai-vault-session.test.ts": 10, + "src/renderer/src/lib/launch-structured-agent-session.test.ts": 726, + "src/renderer/src/lib/launch-work-item-direct-agent-routing.test.ts": 12, + "src/renderer/src/lib/launch-work-item-direct-agent.test.ts": 15, + "src/renderer/src/lib/launch-work-item-direct-messages.test.ts": 6, + "src/renderer/src/lib/launch-work-item-direct.test.ts": 51, + "src/renderer/src/lib/launch-worktree-background-terminals.test.ts": 305, + "src/renderer/src/lib/lazy-chunk-recovery-reload.test.ts": 61, + "src/renderer/src/lib/lazy-with-retry.never-landed-reload.test.ts": 10, + "src/renderer/src/lib/lazy-with-retry.right-sidebar-syntax-error.test.ts": 10, + "src/renderer/src/lib/lazy-with-retry.test.ts": 37, + "src/renderer/src/lib/left-sidebar-appearance.test.ts": 13, + "src/renderer/src/lib/linear-agent-skill-update-command.test.ts": 4, + "src/renderer/src/lib/linear-board-drag-payload.test.ts": 10, + "src/renderer/src/lib/linear-issue-context-snapshot.test.ts": 19, + "src/renderer/src/lib/linear-issue-url-lookup.test.ts": 10, + "src/renderer/src/lib/linear-issue-workspace-attachment.test.ts": 10, + "src/renderer/src/lib/linear-issue-workspace-open.test.ts": 10, + "src/renderer/src/lib/linear-linked-work-item.test.ts": 12, + "src/renderer/src/lib/linked-work-item-context.test.ts": 16, + "src/renderer/src/lib/linked-work-item-provider.test.ts": 5, + "src/renderer/src/lib/list-row-interaction.test.ts": 11, + "src/renderer/src/lib/local-path-open-guard.test.ts": 5, + "src/renderer/src/lib/local-preflight-context-cache.test.ts": 31, + "src/renderer/src/lib/local-preflight-context.test.ts": 18, + "src/renderer/src/lib/locale-text-collators.test.ts": 30, + "src/renderer/src/lib/manual-terminal-worktree-parking.test.ts": 7, + "src/renderer/src/lib/markdown-comment-blocks.test.ts": 6, + "src/renderer/src/lib/markdown-document-templates.test.ts": 9, + "src/renderer/src/lib/markdown-review-note-copy.test.ts": 5, + "src/renderer/src/lib/markdown-review-notes.test.ts": 26, + "src/renderer/src/lib/migration-unsupported-agent-entry.test.ts": 4, + "src/renderer/src/lib/mobile-terminal-tab-mount.test.ts": 7, + "src/renderer/src/lib/monaco-delayer-cancellation-guard.test.ts": 25, + "src/renderer/src/lib/monaco-diff-editor-disposal.test.ts": 13, + "src/renderer/src/lib/monaco-languages/monarch-embed-entry-recursion.test.ts": 388, + "src/renderer/src/lib/monaco-languages/monarch-upstream-mdx-recursion.test.ts": 61, + "src/renderer/src/lib/monaco-languages/register-astro.test.ts": 32, + "src/renderer/src/lib/monaco-languages/register-jsonl.test.ts": 27, + "src/renderer/src/lib/monaco-languages/register-nim.test.ts": 22, + "src/renderer/src/lib/monaco-languages/register-svelte.test.ts": 28, + "src/renderer/src/lib/monaco-languages/register-vue.test.ts": 20, + "src/renderer/src/lib/monaco-languages/textmate-language-registration.test.ts": 10, + "src/renderer/src/lib/monaco-languages/textmate-token-provider.test.ts": 123, + "src/renderer/src/lib/monaco-peek-preview-options.test.ts": 12, + "src/renderer/src/lib/native-chat-initial-view-mode.test.ts": 13, + "src/renderer/src/lib/native-chat-launch-draft-mirrorability.test.ts": 12, + "src/renderer/src/lib/native-chat-transcript-readability.test.ts": 4, + "src/renderer/src/lib/nested-repo-selected-paths.test.ts": 8, + "src/renderer/src/lib/new-workspace-composer-repo.test.ts": 7, + "src/renderer/src/lib/new-workspace-create-gates.test.ts": 4, + "src/renderer/src/lib/new-workspace-enter-guard.test.ts": 18, + "src/renderer/src/lib/new-workspace-project-options.test.ts": 21, + "src/renderer/src/lib/new-workspace-ssh-gate.test.ts": 6, + "src/renderer/src/lib/new-workspace.test.ts": 4395, + "src/renderer/src/lib/non-secure-context-crypto.repro.test.ts": 11, + "src/renderer/src/lib/notes-send-agent-targets.test.ts": 30, + "src/renderer/src/lib/onboarding-project-checklist.test.ts": 6, + "src/renderer/src/lib/open-markdown-in-floating-workspace.test.ts": 7, + "src/renderer/src/lib/open-mobile-emulator-tab.test.ts": 17, + "src/renderer/src/lib/open-tab-occupant-agent.test.ts": 16, + "src/renderer/src/lib/orca-hook-trust.test.ts": 9, + "src/renderer/src/lib/orca-yaml-trust-prompt-slot-eviction.test.ts": 278, + "src/renderer/src/lib/orchestration-setup-state.test.ts": 9, + "src/renderer/src/lib/orchestration-skill-coverage.test.ts": 20, + "src/renderer/src/lib/order-empty-query-worktrees.test.ts": 30, + "src/renderer/src/lib/palette-match/cmd-j-ranking-contract.test.ts": 15, + "src/renderer/src/lib/palette-match/match-field-allocation.test.ts": 49, + "src/renderer/src/lib/palette-match/palette-match-core.test.ts": 26, + "src/renderer/src/lib/palette-match/palette-match-performance.test.ts": 9461, + "src/renderer/src/lib/palette-match/palette-ranking.test.ts": 16, + "src/renderer/src/lib/palette-repo-resolution.test.ts": 9, + "src/renderer/src/lib/palette-type-alias-match.test.ts": 9, + "src/renderer/src/lib/pane-agent-evidence.test.ts": 23, + "src/renderer/src/lib/pane-manager/browser-mobile-driver-state.test.ts": 10, + "src/renderer/src/lib/pane-manager/browser-remote-viewer-state.test.ts": 5, + "src/renderer/src/lib/pane-manager/client-hosted-browser-row-ephemerality.test.ts": 22, + "src/renderer/src/lib/pane-manager/client-hosted-browser-row-state.test.ts": 16, + "src/renderer/src/lib/pane-manager/focus-follows-mouse.test.ts": 7, + "src/renderer/src/lib/pane-manager/mobile-driver-state.test.ts": 10, + "src/renderer/src/lib/pane-manager/mobile-fit-overrides-hydration.test.ts": 5, + "src/renderer/src/lib/pane-manager/mobile-fit-overrides.test.ts": 23, + "src/renderer/src/lib/pane-manager/pane-container-listener-lifecycle.test.ts": 10, + "src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.test.ts": 318, + "src/renderer/src/lib/pane-manager/pane-divider-capture-loss.test.ts": 6, + "src/renderer/src/lib/pane-manager/pane-divider-stray-touch.test.ts": 23, + "src/renderer/src/lib/pane-manager/pane-divider.test.ts": 13, + "src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts": 20, + "src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts": 17, + "src/renderer/src/lib/pane-manager/pane-drop-no-op.test.ts": 12, + "src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts": 16, + "src/renderer/src/lib/pane-manager/pane-fit.test.ts": 61, + "src/renderer/src/lib/pane-manager/pane-initial-fit-lifecycle.test.ts": 10, + "src/renderer/src/lib/pane-manager/pane-key-resolution.test.ts": 9, + "src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts": 52, + "src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts": 26, + "src/renderer/src/lib/pane-manager/pane-metric-options-deferral.test.ts": 14, + "src/renderer/src/lib/pane-manager/pane-overlay-focus.test.ts": 44, + "src/renderer/src/lib/pane-manager/pane-pointer-focus.test.ts": 5, + "src/renderer/src/lib/pane-manager/pane-pty-resize-hold.test.ts": 8, + "src/renderer/src/lib/pane-manager/pane-reveal-fit.test.ts": 10, + "src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts": 53, + "src/renderer/src/lib/pane-manager/pane-scroll.test.ts": 87, + "src/renderer/src/lib/pane-manager/pane-split-close.test.ts": 12, + "src/renderer/src/lib/pane-manager/pane-split-scroll.test.ts": 12, + "src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.test.ts": 8, + "src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.test.ts": 12, + "src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts": 15, + "src/renderer/src/lib/pane-manager/pane-terminal-output-queue-chunks.test.ts": 5, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-ack-credit.test.ts": 69, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-backlog-cap.test.ts": 110, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-disposed-writes.test.ts": 46, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-foreground-refresh.test.ts": 68, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-queue-retention.test.ts": 254, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-synchronized-frames.test.ts": 65, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-throughput.bench.test.ts": 256, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts": 146, + "src/renderer/src/lib/pane-manager/pane-tree-equalization-parity.test.ts": 1130, + "src/renderer/src/lib/pane-manager/pane-tree-equalization-scaling.test.ts": 65, + "src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts": 26, + "src/renderer/src/lib/pane-manager/pane-tree-reparent-frame.test.ts": 30, + "src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts": 50, + "src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts": 8, + "src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts": 38, + "src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.test.ts": 16, + "src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts": 11, + "src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts": 15, + "src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts": 12, + "src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts": 183, + "src/renderer/src/lib/pane-manager/terminal-ime-anchor.test.ts": 11, + "src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.test.ts": 41, + "src/renderer/src/lib/pane-manager/terminal-keyboard-protocol.test.ts": 12, + "src/renderer/src/lib/pane-manager/terminal-ligatures-addon.test.ts": 16, + "src/renderer/src/lib/pane-manager/terminal-link-provider-guard.test.ts": 12, + "src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset-on-mouseleave.test.ts": 14, + "src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset-on-write.test.ts": 13, + "src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset.test.ts": 5, + "src/renderer/src/lib/pane-manager/terminal-render-pause-release-parked-resize.test.ts": 10, + "src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts": 12, + "src/renderer/src/lib/pane-manager/terminal-scroll-intent-input-resync.test.ts": 14, + "src/renderer/src/lib/pane-manager/terminal-scroll-intent-key-retention.test.ts": 55, + "src/renderer/src/lib/pane-manager/terminal-scroll-intent-structural-transitions.test.ts": 13, + "src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts": 22, + "src/renderer/src/lib/pane-manager/terminal-structural-replay-coordinator.test.ts": 16, + "src/renderer/src/lib/pane-manager/terminal-webgl-auto-policy.test.ts": 13, + "src/renderer/src/lib/pane-manager/terminal-webgl-hidden-retention.test.ts": 19, + "src/renderer/src/lib/pane-manager/terminal-windows-ctrl-alt-chord-classification.test.ts": 16, + "src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.test.ts": 19, + "src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts": 20, + "src/renderer/src/lib/pane-manager/xterm-instance-disposed.test.ts": 10, + "src/renderer/src/lib/pane-manager/xterm-user-scrolling-contract.test.ts": 5037, + "src/renderer/src/lib/pane-manager/xterm-write-callback-guard.test.ts": 10, + "src/renderer/src/lib/parked-terminal-host-hydration.test.ts": 12, + "src/renderer/src/lib/passive-macos-app-data-access.test.ts": 6, + "src/renderer/src/lib/paste-payload-metadata.test.ts": 11, + "src/renderer/src/lib/path-head-elision.test.ts": 5, + "src/renderer/src/lib/path.test.ts": 6, + "src/renderer/src/lib/pending-worktree-creation.test.ts": 5, + "src/renderer/src/lib/pi-live-session-no-duplicate-tab.test.ts": 11, + "src/renderer/src/lib/pi-session-resume-wake.test.ts": 12, + "src/renderer/src/lib/plugin-command-execution.test.ts": 7, + "src/renderer/src/lib/plugin-command-keybindings.test.ts": 19, + "src/renderer/src/lib/pr-bot-author-overrides.test.ts": 213, + "src/renderer/src/lib/pr-comment-action-state.test.ts": 12, + "src/renderer/src/lib/pr-comment-reactions.test.ts": 6, + "src/renderer/src/lib/primary-selection-paste.test.ts": 79, + "src/renderer/src/lib/primary-selection.test.ts": 9, + "src/renderer/src/lib/project-clone-url-prefill.test.ts": 15, + "src/renderer/src/lib/project-host-clone-url.test.ts": 8, + "src/renderer/src/lib/project-host-setup-options.test.ts": 20, + "src/renderer/src/lib/project-host-workspace-target.test.ts": 17, + "src/renderer/src/lib/project-skill-runtime.test.ts": 8, + "src/renderer/src/lib/provisioned-root-create-options.test.ts": 8, + "src/renderer/src/lib/quick-workspace-agent-selection.test.ts": 7, + "src/renderer/src/lib/react-commit-cascade-install-order.test.ts": 8, + "src/renderer/src/lib/react-commit-cascade-observer.react185.test.tsx": 44, + "src/renderer/src/lib/react-commit-cascade-observer.test.ts": 150, + "src/renderer/src/lib/react-commit-cascade-store-write-samples.test.ts": 12, + "src/renderer/src/lib/react-commit-cascade-telemetry.test.ts": 108, + "src/renderer/src/lib/react-error-boundary-reporting.test.ts": 15, + "src/renderer/src/lib/react-grab-dev-gate.test.ts": 4, + "src/renderer/src/lib/react-renderer-root.test.ts": 6, + "src/renderer/src/lib/recent-workspace-tab-rows.test.ts": 19, + "src/renderer/src/lib/remap-open-editor-tabs-for-path-change.test.ts": 62, + "src/renderer/src/lib/renderer-agent-status-observation-ingress.test.ts": 36, + "src/renderer/src/lib/renderer-app-platform.test.ts": 34, + "src/renderer/src/lib/renderer-memory-profile.test.ts": 26, + "src/renderer/src/lib/repo-display-labels.test.ts": 6, + "src/renderer/src/lib/repo-runtime-owner.test.ts": 14, + "src/renderer/src/lib/repo-search.test.ts": 10, + "src/renderer/src/lib/repo-slug-cache.test.ts": 8, + "src/renderer/src/lib/repo-slug-index.test.ts": 515, + "src/renderer/src/lib/resolved-worktree-execution-host.test.ts": 7, + "src/renderer/src/lib/resume-sleeping-agent-session-direct-ssh-hydration-gap.test.ts": 22, + "src/renderer/src/lib/resume-sleeping-agent-session-provider-claim.test.ts": 13, + "src/renderer/src/lib/resume-sleeping-agent-session-remote-compat.test.ts": 25, + "src/renderer/src/lib/resume-sleeping-agent-session-replay.test.ts": 65, + "src/renderer/src/lib/resume-sleeping-agent-session-slept-pane-recovery.test.ts": 11, + "src/renderer/src/lib/resume-sleeping-agent-session-suppress-navigation.test.ts": 32, + "src/renderer/src/lib/resume-sleeping-agent-session.test.ts": 126, + "src/renderer/src/lib/resume-stale-structured-agent-session.test.ts": 9, + "src/renderer/src/lib/right-sidebar-visibility.test.ts": 8, + "src/renderer/src/lib/run-quick-command-in-new-tab.test.ts": 10, + "src/renderer/src/lib/running-agent-targets.test.ts": 12, + "src/renderer/src/lib/runtime-pane-title-leaf-id.test.ts": 9, + "src/renderer/src/lib/runtime-session-mirror-targets.test.ts": 6, + "src/renderer/src/lib/runtime-workspace-file-route.test.ts": 14, + "src/renderer/src/lib/screen-submit-shortcut.test.ts": 6, + "src/renderer/src/lib/script-textarea-rows.test.ts": 52, + "src/renderer/src/lib/scroll-cache.test.ts": 9, + "src/renderer/src/lib/serve-desktop-promotion-session-continuity.test.ts": 13, + "src/renderer/src/lib/session-write-subscriber-allocation.test.ts": 21, + "src/renderer/src/lib/session-write-subscriber-deferred-persist.test.ts": 21, + "src/renderer/src/lib/session-write-subscriber.test.ts": 107, + "src/renderer/src/lib/settled-worker-wake-policy.test.ts": 25, + "src/renderer/src/lib/setup-runner.test.ts": 4, + "src/renderer/src/lib/setup-script-prompt.test.ts": 20, + "src/renderer/src/lib/sha256.test.ts": 22, + "src/renderer/src/lib/shutdown-checkpoint-guard.test.ts": 26, + "src/renderer/src/lib/sidebar-worktree-activation.test.ts": 13, + "src/renderer/src/lib/simulator-launch-coordination.test.ts": 112, + "src/renderer/src/lib/simulator-palette-search.test.ts": 29, + "src/renderer/src/lib/simulator-pane-shutdown-scheduler.test.ts": 17, + "src/renderer/src/lib/simulator-tab-palette-activation.test.ts": 32, + "src/renderer/src/lib/skill-freshness-display-status.test.ts": 16, + "src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts": 290, + "src/renderer/src/lib/smart-github-submit.test.ts": 19, + "src/renderer/src/lib/source-control-agent-action-plan.test.ts": 15, + "src/renderer/src/lib/source-control-generation-plan.test.ts": 16, + "src/renderer/src/lib/source-control-launch-agent-selection.test.ts": 12, + "src/renderer/src/lib/source-control-launch-platform.test.ts": 10, + "src/renderer/src/lib/source-control-remote-error.test.ts": 27, + "src/renderer/src/lib/sparse-preset-draft.test.ts": 21, + "src/renderer/src/lib/ssh-background-startup-delivery.test.ts": 17, + "src/renderer/src/lib/ssh-mutation-expectation.test.ts": 7, + "src/renderer/src/lib/startup-ui-hydration.test.ts": 10, + "src/renderer/src/lib/state-collection-byte-estimate.test.ts": 46, + "src/renderer/src/lib/structured-agent-launch-settlement-caller-census.test.ts": 361, + "src/renderer/src/lib/structured-agent-launch-settlement.test.ts": 67, + "src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts": 8, + "src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts": 12, + "src/renderer/src/lib/structured-agent-session-launch-refusal-fallback.test.ts": 25, + "src/renderer/src/lib/structured-agent-session-launch-resume-identity.test.ts": 16, + "src/renderer/src/lib/structured-agent-session-launch.test.ts": 307, + "src/renderer/src/lib/structured-agent-session-tab-activation.test.ts": 7, + "src/renderer/src/lib/tab-agent-identity-decision-table.test.ts": 438, + "src/renderer/src/lib/tab-agent-status-index.test.ts": 162, + "src/renderer/src/lib/tab-agent.test.ts": 10, + "src/renderer/src/lib/tab-has-live-pty.test.ts": 7, + "src/renderer/src/lib/tab-number-shortcuts.test.ts": 9, + "src/renderer/src/lib/terminal-contrast-correction.test.ts": 13, + "src/renderer/src/lib/terminal-file-uri-link.test.ts": 8, + "src/renderer/src/lib/terminal-input-activity-coalescing.test.ts": 12, + "src/renderer/src/lib/terminal-links-redos.test.ts": 12, + "src/renderer/src/lib/terminal-links.test.ts": 331, + "src/renderer/src/lib/terminal-pane-title-sanitization.test.ts": 7, + "src/renderer/src/lib/terminal-quick-command-project-scope.test.ts": 7, + "src/renderer/src/lib/terminal-quick-command-search.test.ts": 11, + "src/renderer/src/lib/terminal-reveal-identity.test.ts": 10, + "src/renderer/src/lib/terminal-shortcut-capture-notification.test.tsx": 16, + "src/renderer/src/lib/terminal-tab-for-pty-id.test.ts": 10, + "src/renderer/src/lib/terminal-theme.test.ts": 22, + "src/renderer/src/lib/terminal-worktree-route.test.ts": 16, + "src/renderer/src/lib/text-control-paste-ownership.test.ts": 21, + "src/renderer/src/lib/text-control-paste.test.ts": 30, + "src/renderer/src/lib/titlebar-left-chrome.test.ts": 6, + "src/renderer/src/lib/titlebar-worktree-history-controls.test.ts": 4, + "src/renderer/src/lib/tui-agent-startup.test.ts": 18, + "src/renderer/src/lib/typing-latency/diagnostic-lifecycle.test.ts": 14, + "src/renderer/src/lib/typing-latency/diagnostic-summary.test.ts": 13, + "src/renderer/src/lib/typing-latency/echo-instrumentation.test.ts": 28, + "src/renderer/src/lib/typing-latency/input-events.test.ts": 7, + "src/renderer/src/lib/typing-latency/input-source.test.ts": 8, + "src/renderer/src/lib/typing-latency/sample-window.test.ts": 10, + "src/renderer/src/lib/unread-badge-count.test.ts": 4, + "src/renderer/src/lib/update-check-click-options.test.ts": 5, + "src/renderer/src/lib/updater-beforeunload.test.ts": 8, + "src/renderer/src/lib/use-tab-agent-observed-signal-dispatch.test.tsx": 63, + "src/renderer/src/lib/use-tab-agent-opencode-native-title.test.ts": 40, + "src/renderer/src/lib/use-tab-agent-pi-identity.test.ts": 27, + "src/renderer/src/lib/use-tab-agent-process-signals.test.ts": 48, + "src/renderer/src/lib/use-tab-agent-remote-pty-selector.test.ts": 30, + "src/renderer/src/lib/use-tab-agent-retained-identity.test.ts": 36, + "src/renderer/src/lib/use-tab-agent-sleeping-session.test.ts": 37, + "src/renderer/src/lib/use-tab-agent.test.ts": 112, + "src/renderer/src/lib/visible-overlay.test.ts": 15, + "src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts": 19, + "src/renderer/src/lib/wake-sleeping-agents-live-done.test.ts": 10, + "src/renderer/src/lib/web-client-location.test.ts": 6, + "src/renderer/src/lib/window-label-formatter.test.ts": 13, + "src/renderer/src/lib/window-visibility-interval.test.ts": 15, + "src/renderer/src/lib/window-visibility-timeout-poller.test.ts": 10, + "src/renderer/src/lib/windows-terminal-capabilities-race.test.ts": 7, + "src/renderer/src/lib/windows-terminal-capabilities.test.ts": 64, + "src/renderer/src/lib/windows-terminal-capability-reprobe.test.ts": 25, + "src/renderer/src/lib/work-item-link-query-bounds.test.ts": 5, + "src/renderer/src/lib/work-item-lookup-text.test.ts": 8, + "src/renderer/src/lib/worker-terminal-takeover-report.test.ts": 257, + "src/renderer/src/lib/workspace-activation-path-gate.test.ts": 23, + "src/renderer/src/lib/workspace-activation-terminal-focus.test.ts": 270, + "src/renderer/src/lib/workspace-browser-tab-open.test.ts": 37, + "src/renderer/src/lib/workspace-composer-initial-focus.test.ts": 16, + "src/renderer/src/lib/workspace-create-error-format.test.ts": 9, + "src/renderer/src/lib/workspace-doc-address-input.test.ts": 12, + "src/renderer/src/lib/workspace-emoji-shortcodes.lazy.test.ts": 60, + "src/renderer/src/lib/workspace-emoji-shortcodes.test.ts": 29, + "src/renderer/src/lib/workspace-file-drag.test.ts": 7, + "src/renderer/src/lib/workspace-port-groups.test.ts": 5, + "src/renderer/src/lib/workspace-port-host-availability.test.ts": 12, + "src/renderer/src/lib/workspace-port-scan-debounce.test.ts": 12, + "src/renderer/src/lib/workspace-port-scan-publish.test.ts": 24, + "src/renderer/src/lib/workspace-session-browser-history.test.ts": 12, + "src/renderer/src/lib/workspace-session-closed-tab-tombstones.test.ts": 7, + "src/renderer/src/lib/workspace-session-editor-drafts.test.ts": 9, + "src/renderer/src/lib/workspace-session-host-contention.test.ts": 18, + "src/renderer/src/lib/workspace-session-host-persistence.test.ts": 22, + "src/renderer/src/lib/workspace-session-host-split.test.ts": 14, + "src/renderer/src/lib/workspace-session-hydration-keys.test.ts": 8, + "src/renderer/src/lib/workspace-session-liveness.test.ts": 8, + "src/renderer/src/lib/workspace-session-patch.test.ts": 12, + "src/renderer/src/lib/workspace-session-persistence-gate.test.ts": 8, + "src/renderer/src/lib/workspace-session-relevant-fields.test.ts": 6, + "src/renderer/src/lib/workspace-session-staged-browser-tabs.test.ts": 5, + "src/renderer/src/lib/workspace-session.test.ts": 20, + "src/renderer/src/lib/workspace-tab-agent-metadata.test.ts": 11, + "src/renderer/src/lib/workspace-tab-palette-activation.store.test.ts": 10, + "src/renderer/src/lib/workspace-tab-palette-activation.test.ts": 19, + "src/renderer/src/lib/workspace-tab-palette-results.test.ts": 19, + "src/renderer/src/lib/workspace-tab-palette-search.test.ts": 37, + "src/renderer/src/lib/workspace-terminal-host-authority.test.ts": 38, + "src/renderer/src/lib/worktree-activation-agent-startup.test.ts": 42, + "src/renderer/src/lib/worktree-activation-automation-filter.test.ts": 19, + "src/renderer/src/lib/worktree-activation-created-agent.test.ts": 72, + "src/renderer/src/lib/worktree-activation-default-tabs.test.ts": 33, + "src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts": 71, + "src/renderer/src/lib/worktree-activation-empty-remote.test.ts": 63, + "src/renderer/src/lib/worktree-activation-issue-command.test.ts": 21, + "src/renderer/src/lib/worktree-activation-pty-inventory.test.ts": 18, + "src/renderer/src/lib/worktree-activation-reveal.test.ts": 18, + "src/renderer/src/lib/worktree-activation-setup-script.test.ts": 67, + "src/renderer/src/lib/worktree-activation-structured-chat-surface.test.ts": 43, + "src/renderer/src/lib/worktree-activation-surface-caller-wiring.test.ts": 366, + "src/renderer/src/lib/worktree-activation-web-runtime.test.ts": 198, + "src/renderer/src/lib/worktree-activity-state.test.ts": 8, + "src/renderer/src/lib/worktree-agent-activation-gate.test.ts": 36, + "src/renderer/src/lib/worktree-agent-activation-seam.test.ts": 37, + "src/renderer/src/lib/worktree-agent-structured-inventory.test.ts": 8, + "src/renderer/src/lib/worktree-attachment-label.test.ts": 6, + "src/renderer/src/lib/worktree-creation-agent-seeding.test.ts": 11, + "src/renderer/src/lib/worktree-creation-agent-seeds.test.ts": 107, + "src/renderer/src/lib/worktree-creation-chat-setup.test.ts": 23, + "src/renderer/src/lib/worktree-creation-flow-agent-trust-preflight.test.ts": 3, + "src/renderer/src/lib/worktree-creation-flow-dedupe.test.ts": 7, + "src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts": 123, + "src/renderer/src/lib/worktree-creation-flow.test.ts": 879, + "src/renderer/src/lib/worktree-creation-structured-session.test.ts": 28, + "src/renderer/src/lib/worktree-creation-structured-unknown-outcome.test.ts": 9, + "src/renderer/src/lib/worktree-creation-surface.test.ts": 4, + "src/renderer/src/lib/worktree-default-display-name.test.ts": 11, + "src/renderer/src/lib/worktree-display-name-order.test.ts": 13, + "src/renderer/src/lib/worktree-draft-startup-view-mode.test.ts": 12, + "src/renderer/src/lib/worktree-git-identity-display.test.ts": 4, + "src/renderer/src/lib/worktree-jump-navigation.test.ts": 21, + "src/renderer/src/lib/worktree-live-terminal-surface-owners.test.ts": 13, + "src/renderer/src/lib/worktree-operation-generation.test.ts": 7, + "src/renderer/src/lib/worktree-operation-route.test.ts": 20, + "src/renderer/src/lib/worktree-palette-comment-snippet.test.ts": 13, + "src/renderer/src/lib/worktree-palette-create-action.test.ts": 7, + "src/renderer/src/lib/worktree-palette-multi-keyword.test.ts": 143, + "src/renderer/src/lib/worktree-palette-review-match.test.ts": 9, + "src/renderer/src/lib/worktree-palette-runtime-owner-identity.test.ts": 14, + "src/renderer/src/lib/worktree-palette-search.test.ts": 62, + "src/renderer/src/lib/worktree-palette-task-url-match.test.ts": 25, + "src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts": 53, + "src/renderer/src/lib/worktree-reactivation-runtime-owned-resume-deferral.test.ts": 30, + "src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts": 30, + "src/renderer/src/lib/worktree-runtime-owner-index.detected-perf.test.ts": 411, + "src/renderer/src/lib/worktree-runtime-owner-index.detected.test.ts": 80, + "src/renderer/src/lib/worktree-runtime-owner-index.test.ts": 7, + "src/renderer/src/lib/worktree-runtime-owner.test.ts": 64, + "src/renderer/src/lib/worktree-sort-order-host-split.test.ts": 5, + "src/renderer/src/lib/worktree-sort-order-persistence.test.ts": 29, + "src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts": 15, + "src/renderer/src/lib/worktree-status-terminal-layout-roots.test.ts": 4, + "src/renderer/src/lib/worktree-status.interrupted.test.ts": 8, + "src/renderer/src/lib/worktree-status.test.ts": 16, + "src/renderer/src/lib/worktree-visit-recency.test.ts": 6, + "src/renderer/src/renderer-node-builtin-boundary.test.ts": 1808, + "src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts": 11, + "src/renderer/src/runtime/agent-session-operation-id.test.ts": 6, + "src/renderer/src/runtime/browser-client-host-identity.test.ts": 7, + "src/renderer/src/runtime/browser-workspace-tab-close-census.test.ts": 221, + "src/renderer/src/runtime/browser-workspace-tab-close-plan.test.ts": 11, + "src/renderer/src/runtime/browser-workspace-tab-close.test.ts": 21, + "src/renderer/src/runtime/client-hosted-browser-close-intent-replay.test.ts": 11, + "src/renderer/src/runtime/client-hosted-browser-close-intents.test.ts": 16, + "src/renderer/src/runtime/client-hosted-browser-row-close.test.ts": 13, + "src/renderer/src/runtime/close-mirrored-editor-tab.test.ts": 61, + "src/renderer/src/runtime/file-explorer-delete-owner-provenance.test.ts": 47, + "src/renderer/src/runtime/focus-runtime-terminal-surface-chat-view.test.ts": 13, + "src/renderer/src/runtime/github-check-details-timeout.test.ts": 14, + "src/renderer/src/runtime/gitlab-job-trace-client.test.ts": 25, + "src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts": 37, + "src/renderer/src/runtime/host-session-mirror-hydration-frame-ordering.test.tsx": 120, + "src/renderer/src/runtime/host-session-mirror-settle-census.test.ts": 76, + "src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx": 165, + "src/renderer/src/runtime/host-session-snapshot-authority-client-hosted.test.ts": 4, + "src/renderer/src/runtime/local-runtime-capabilities.test.ts": 10, + "src/renderer/src/runtime/local-session-tab-close-owner.test.ts": 9, + "src/renderer/src/runtime/local-structured-session-empty-worktree-visibility.test.ts": 17, + "src/renderer/src/runtime/local-structured-session-retired-epoch-repair.test.ts": 21, + "src/renderer/src/runtime/local-structured-session-reveal-visibility.test.ts": 35, + "src/renderer/src/runtime/local-structured-session-tabs-host-isolation.test.ts": 28, + "src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts": 210, + "src/renderer/src/runtime/local-structured-session-tabs-sync/retired-epoch-repair.test.ts": 16, + "src/renderer/src/runtime/mirrored-agent-status-clock-skew.test.ts": 51, + "src/renderer/src/runtime/mobile-markdown-bridge-save-guards.test.ts": 58, + "src/renderer/src/runtime/mobile-markdown-bridge.test.ts": 31, + "src/renderer/src/runtime/native-chat-launch-draft-runtime-resolution.test.ts": 6, + "src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts": 146, + "src/renderer/src/runtime/remote-agent-row-last-assistant-message.test.ts": 98, + "src/renderer/src/runtime/remote-agent-session-launch.test.ts": 11, + "src/renderer/src/runtime/remote-host-file-delete-repro.test.ts": 30, + "src/renderer/src/runtime/remote-host-file-open-repro.test.ts": 16, + "src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts": 11, + "src/renderer/src/runtime/remote-runtime-snapshot-outcome.test.ts": 28, + "src/renderer/src/runtime/remote-runtime-terminal-end-verdict.test.ts": 49, + "src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts": 47, + "src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts": 296, + "src/renderer/src/runtime/remote-runtime-terminal-snapshot-kitty-flags.test.ts": 17, + "src/renderer/src/runtime/remote-runtime-terminal-stale-stream-frames.test.ts": 9, + "src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts": 145, + "src/renderer/src/runtime/remote-server-install-failure-probe.test.ts": 9, + "src/renderer/src/runtime/remote-server-parity.test.ts": 28, + "src/renderer/src/runtime/remote-server-restart-wait.test.ts": 7, + "src/renderer/src/runtime/remote-server-update-coordinator.test.ts": 24, + "src/renderer/src/runtime/remote-terminal-layout-resolution.test.ts": 14, + "src/renderer/src/runtime/remote-terminal-stream-watchdog.test.ts": 13, + "src/renderer/src/runtime/restored-client-hosted-browser-host-attach.test.ts": 16, + "src/renderer/src/runtime/restored-client-hosted-browser-host-restart-attach.test.ts": 15, + "src/renderer/src/runtime/runtime-client-events.test.ts": 9, + "src/renderer/src/runtime/runtime-environment-ssh-state.test.ts": 148, + "src/renderer/src/runtime/runtime-file-client-download.test.ts": 21, + "src/renderer/src/runtime/runtime-file-client-external-import.test.ts": 20, + "src/renderer/src/runtime/runtime-file-client-mutation-ownership.test.ts": 17, + "src/renderer/src/runtime/runtime-file-client-search-listing.test.ts": 129, + "src/renderer/src/runtime/runtime-file-client-watch.test.ts": 73, + "src/renderer/src/runtime/runtime-file-client.test.ts": 18, + "src/renderer/src/runtime/runtime-file-import-pairing-revision.test.ts": 22, + "src/renderer/src/runtime/runtime-file-search-bounds.test.ts": 7, + "src/renderer/src/runtime/runtime-git-client-api-contract.test.ts": 4, + "src/renderer/src/runtime/runtime-git-client-branch-line-total.test.ts": 13, + "src/renderer/src/runtime/runtime-git-client-merge.test.ts": 11, + "src/renderer/src/runtime/runtime-git-client.test.ts": 32, + "src/renderer/src/runtime/runtime-hooks-client.test.ts": 9, + "src/renderer/src/runtime/runtime-host-connection-state.test.ts": 7, + "src/renderer/src/runtime/runtime-jira-client.test.ts": 17, + "src/renderer/src/runtime/runtime-jira-payload-stream.test.ts": 10, + "src/renderer/src/runtime/runtime-linear-client.test.ts": 25, + "src/renderer/src/runtime/runtime-provider-accounts-client.test.ts": 37, + "src/renderer/src/runtime/runtime-provider-search-bounds.test.ts": 7, + "src/renderer/src/runtime/runtime-repo-client.test.ts": 7, + "src/renderer/src/runtime/runtime-repo-search-bounds.test.ts": 7, + "src/renderer/src/runtime/runtime-rpc-client-pairing-revision.test.ts": 7, + "src/renderer/src/runtime/runtime-rpc-client.test.ts": 22, + "src/renderer/src/runtime/runtime-rpc-result.test.ts": 9, + "src/renderer/src/runtime/runtime-server-directory-browser.test.ts": 9, + "src/renderer/src/runtime/runtime-skills-client.test.ts": 12, + "src/renderer/src/runtime/runtime-skills-delete-client.test.ts": 15, + "src/renderer/src/runtime/runtime-terminal-inspection.test.ts": 683, + "src/renderer/src/runtime/runtime-terminal-stream.test.ts": 286, + "src/renderer/src/runtime/runtime-worktree-selector.test.ts": 6, + "src/renderer/src/runtime/structured-agent-session-client.test.ts": 14, + "src/renderer/src/runtime/structured-agent-session-close.test.ts": 8, + "src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts": 11, + "src/renderer/src/runtime/structured-agent-session-status-feed.test.ts": 18, + "src/renderer/src/runtime/structured-conversation-tab-replacement.test.ts": 20, + "src/renderer/src/runtime/sync-runtime-graph-agent-status-projection-join.test.ts": 51, + "src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts": 20, + "src/renderer/src/runtime/sync-runtime-graph-automation-leaf.test.ts": 16, + "src/renderer/src/runtime/sync-runtime-graph-browser.test.ts": 15, + "src/renderer/src/runtime/sync-runtime-graph-conversion-publish.test.ts": 83, + "src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts": 19, + "src/renderer/src/runtime/sync-runtime-graph-key-reuse.test.ts": 19, + "src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts": 29, + "src/renderer/src/runtime/sync-runtime-graph-payload-partition.test.ts": 32, + "src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts": 128, + "src/renderer/src/runtime/sync-runtime-graph-publication-cost.test.ts": 55, + "src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts": 24, + "src/renderer/src/runtime/sync-runtime-graph-terminal-layout.test.ts": 696, + "src/renderer/src/runtime/sync-runtime-graph-terminal-registration-ownership.test.ts": 13, + "src/renderer/src/runtime/sync-runtime-graph-terminal-surface-projection.test.ts": 30, + "src/renderer/src/runtime/sync-runtime-graph-workspace-publication.test.ts": 19, + "src/renderer/src/runtime/sync-runtime-graph.test.ts": 20, + "src/renderer/src/runtime/sync-runtime-graph/mobile-terminal-theme.test.ts": 4, + "src/renderer/src/runtime/use-remote-runtime-recovery-triggers.test.ts": 22, + "src/renderer/src/runtime/use-runtime-session-mirror-environment-key.test.ts": 78, + "src/renderer/src/runtime/use-worktree-runtime-target.test.ts": 58, + "src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts": 16, + "src/renderer/src/runtime/web-runtime-browser-materialization.test.ts": 4, + "src/renderer/src/runtime/web-runtime-browser-tab-staging-hosting-intent.test.ts": 9, + "src/renderer/src/runtime/web-runtime-session-browser-client-placement.test.ts": 81, + "src/renderer/src/runtime/web-runtime-session-browser-create-failure.test.ts": 112, + "src/renderer/src/runtime/web-runtime-session-browser-create-focus.test.ts": 283, + "src/renderer/src/runtime/web-runtime-session-browser-create-split-placement.test.ts": 231, + "src/renderer/src/runtime/web-runtime-session-browser-create-staged-focus.test.ts": 118, + "src/renderer/src/runtime/web-runtime-session-browser-create-staged-hosting-intent.test.ts": 32, + "src/renderer/src/runtime/web-runtime-session-browser-create-staging.test.ts": 320, + "src/renderer/src/runtime/web-runtime-session-browser-placement-staleness.test.ts": 22, + "src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts": 27, + "src/renderer/src/runtime/web-runtime-session-tab-move.test.ts": 11, + "src/renderer/src/runtime/web-runtime-session-tab-props.test.ts": 116, + "src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts": 38, + "src/renderer/src/runtime/web-runtime-session-terminal-legacy-create.test.ts": 35, + "src/renderer/src/runtime/web-runtime-session-terminal-pane-delegation.test.ts": 590, + "src/renderer/src/runtime/web-runtime-session-terminal-workspace-routing.test.ts": 16, + "src/renderer/src/runtime/web-runtime-session.test.ts": 16, + "src/renderer/src/runtime/web-runtime-wake-terminal-respawn.test.ts": 4, + "src/renderer/src/runtime/web-session-browser-placement.test.ts": 8, + "src/renderer/src/runtime/web-session-close-intent.test.ts": 7, + "src/renderer/src/runtime/web-session-existing-tab-index.test.ts": 7, + "src/renderer/src/runtime/web-session-focus-intent-visible-tab.test.ts": 9, + "src/renderer/src/runtime/web-session-intent-owner.test.ts": 7, + "src/renderer/src/runtime/web-session-open-files-batch-equivalence.test.ts": 333, + "src/renderer/src/runtime/web-session-structured-tab-focus.test.ts": 11, + "src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts": 40, + "src/renderer/src/runtime/web-session-tabs-sync-agent-handoff.test.ts": 15, + "src/renderer/src/runtime/web-session-tabs-sync-agent-status.test.ts": 19, + "src/renderer/src/runtime/web-session-tabs-sync-browser-tabs.test.ts": 29, + "src/renderer/src/runtime/web-session-tabs-sync-client-owned-page-content.test.ts": 22, + "src/renderer/src/runtime/web-session-tabs-sync-client-owned-placement.test.ts": 36, + "src/renderer/src/runtime/web-session-tabs-sync-diff-focus-steal.test.ts": 19, + "src/renderer/src/runtime/web-session-tabs-sync-editor-tabs.test.ts": 15, + "src/renderer/src/runtime/web-session-tabs-sync-focus-intent.test.ts": 26, + "src/renderer/src/runtime/web-session-tabs-sync-host-restart-browser-rows.test.ts": 22, + "src/renderer/src/runtime/web-session-tabs-sync-host-tab-retraction-ghost-rows.test.ts": 138, + "src/renderer/src/runtime/web-session-tabs-sync-html-preview-focus.test.ts": 21, + "src/renderer/src/runtime/web-session-tabs-sync-layout-duplicate-groups.test.ts": 15, + "src/renderer/src/runtime/web-session-tabs-sync-layout-groups.test.ts": 22, + "src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts": 54, + "src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts": 109, + "src/renderer/src/runtime/web-session-tabs-sync-restored-browser-rows.test.ts": 17, + "src/renderer/src/runtime/web-session-tabs-sync-snapshot-batch.test.ts": 36, + "src/renderer/src/runtime/web-session-tabs-sync-staged-browser-adoption.test.ts": 15, + "src/renderer/src/runtime/web-session-tabs-sync-staged-browser-authority.test.ts": 12, + "src/renderer/src/runtime/web-session-tabs-sync-terminal-bootstrap.test.ts": 5, + "src/renderer/src/runtime/web-session-tabs-sync-terminal-mirroring.test.ts": 30, + "src/renderer/src/runtime/web-session-tabs-sync-tracking-teardown.test.ts": 18, + "src/renderer/src/runtime/web-session-tabs-sync-visibility-collision.test.tsx": 113, + "src/renderer/src/runtime/web-session-tabs-sync-window-visibility.test.tsx": 170, + "src/renderer/src/runtime/web-session-tabs-sync.test.ts": 29, + "src/renderer/src/runtime/web-session-tabs-sync/mirrored-agent-tab-label.test.ts": 7, + "src/renderer/src/runtime/web-session-tabs-sync/mirrored-terminal-recovery-ledger.test.ts": 9, + "src/renderer/src/runtime/web-session-terminal-handle-events.test.ts": 6, + "src/renderer/src/runtime/web-session-terminal-orphan-absence-across-republication.test.ts": 11, + "src/renderer/src/runtime/web-session-terminal-orphan-inventory-retry.test.ts": 43, + "src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts": 49, + "src/renderer/src/runtime/web-session-terminal-orphan-recovery-adoption-regressions.test.ts": 179, + "src/renderer/src/runtime/web-session-terminal-orphan-recovery-host-scope-gate.test.ts": 10, + "src/renderer/src/runtime/web-session-terminal-orphan-recovery-prior-removal.test.ts": 15, + "src/renderer/src/runtime/web-session-terminal-orphan-recovery-regressions.test.ts": 295, + "src/renderer/src/runtime/web-session-terminal-orphan-recovery-topology-fence.test.ts": 168, + "src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts": 122, + "src/renderer/src/runtime/web-session-terminal-pending-handle-recovery.test.ts": 33, + "src/renderer/src/runtime/web-session-terminal-recovery-snapshot-validation.test.ts": 155, + "src/renderer/src/runtime/web-session-terminal-retirement-proof-ledger.test.ts": 11, + "src/renderer/src/runtime/window-visibility-subscription-parking.test.ts": 26, + "src/renderer/src/runtime/worktree-create-base.test.ts": 4, + "src/renderer/src/ssh/ssh-connect-in-flight.test.ts": 21, + "src/renderer/src/ssh/ssh-connect-ui-timeout.test.ts": 15, + "src/renderer/src/ssh/ssh-connect-verb.test.ts": 7, + "src/renderer/src/ssh/ssh-connection-recoverability.test.ts": 6, + "src/renderer/src/startup/active-workspace-ssh-targets.test.ts": 7, + "src/renderer/src/startup/ssh-startup-reconnect.test.ts": 16, + "src/renderer/src/startup/startup-ssh-connection-restore.test.ts": 13, + "src/renderer/src/store/active-terminal-chrome-selector.test.ts": 17, + "src/renderer/src/store/always-mounted-selector-scan-cost.test.ts": 116, + "src/renderer/src/store/copy-on-write-record.test.ts": 6, + "src/renderer/src/store/folder-workspaces/folder-workspace-catalog.test.ts": 14, + "src/renderer/src/store/pinned-tab-close-guard.test.ts": 13, + "src/renderer/src/store/plugin-language-packs.test.ts": 21, + "src/renderer/src/store/plugin-panels.test.ts": 13, + "src/renderer/src/store/project-host-setup-selector.test.ts": 18, + "src/renderer/src/store/projects/project-catalog-null-field-ingest.test.ts": 12, + "src/renderer/src/store/projects/project-wsl-filesystem-boundary-advisory.test.ts": 13, + "src/renderer/src/store/react-commit-cascade-write-probe.test.ts": 5, + "src/renderer/src/store/repos/safe-auto-fork-sync.test.ts": 8, + "src/renderer/src/store/right-sidebar-route.test.ts": 6, + "src/renderer/src/store/running-terminal-close-confirm.test.ts": 7, + "src/renderer/src/store/selectors.test.ts": 44, + "src/renderer/src/store/slices/active-tab-owner-worktree.test.ts": 12, + "src/renderer/src/store/slices/activity-cleared-at.test.ts": 82, + "src/renderer/src/store/slices/agent-generated-tab-title.test.ts": 141, + "src/renderer/src/store/slices/agent-hibernation-live-anchor-e2e.test.ts": 53, + "src/renderer/src/store/slices/agent-pane-authority.test.ts": 110, + "src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts": 99, + "src/renderer/src/store/slices/agent-status-batch.test.ts": 154, + "src/renderer/src/store/slices/agent-status-drop-ipc.test.ts": 705, + "src/renderer/src/store/slices/agent-status-drop.test.ts": 88, + "src/renderer/src/store/slices/agent-status-freshness-cache.test.ts": 10, + "src/renderer/src/store/slices/agent-status-freshness-scheduler.test.ts": 20, + "src/renderer/src/store/slices/agent-status-live-freshness-request.test.ts": 7, + "src/renderer/src/store/slices/agent-status-live-map-leak.test.ts": 10291, + "src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts": 90, + "src/renderer/src/store/slices/agent-status-observation-neutrality.test.ts": 107, + "src/renderer/src/store/slices/agent-status-pane-keyed-records.test.ts": 26, + "src/renderer/src/store/slices/agent-status-pr-refresh-handoff.test.ts": 89, + "src/renderer/src/store/slices/agent-status-provider-session.test.ts": 117, + "src/renderer/src/store/slices/agent-status-quit-capture-resumable-agents.test.ts": 64, + "src/renderer/src/store/slices/agent-status-quit-capture.test.ts": 170, + "src/renderer/src/store/slices/agent-status-reminted-pane-key.test.ts": 41, + "src/renderer/src/store/slices/agent-status-retained-leak.test.ts": 846, + "src/renderer/src/store/slices/agent-status-retention-prefix-sweep.test.ts": 85, + "src/renderer/src/store/slices/agent-status-runtime-orchestration.test.ts": 96, + "src/renderer/src/store/slices/agent-status-session-boundary-done.test.ts": 60, + "src/renderer/src/store/slices/agent-status-ssh-connection-clear.test.ts": 64, + "src/renderer/src/store/slices/agent-status-tool-assistant-fields.test.ts": 128, + "src/renderer/src/store/slices/agent-status-worktree-purge-leak.test.ts": 50, + "src/renderer/src/store/slices/agent-status.test.ts": 54, + "src/renderer/src/store/slices/ambiguous-owner-warning-worktree-removal-leak.test.ts": 587, + "src/renderer/src/store/slices/browser-cleanup-close.test.ts": 151, + "src/renderer/src/store/slices/browser-page-close-intent-recording.test.ts": 191, + "src/renderer/src/store/slices/browser-page-conversion.test.ts": 125, + "src/renderer/src/store/slices/browser-remote-page-lifecycle.test.ts": 221, + "src/renderer/src/store/slices/browser-remote-tab-creation.test.ts": 42, + "src/renderer/src/store/slices/browser-session-host-selection.test.ts": 225, + "src/renderer/src/store/slices/browser-session-profiles.test.ts": 21, + "src/renderer/src/store/slices/browser-webview-cleanup.test.ts": 7, + "src/renderer/src/store/slices/browser-workspace-doc-location.test.ts": 171, + "src/renderer/src/store/slices/browser-workspace-host-ownership.test.ts": 18, + "src/renderer/src/store/slices/browser.test.ts": 43, + "src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts": 89, + "src/renderer/src/store/slices/cmd-j-create-actions.test.ts": 90, + "src/renderer/src/store/slices/codex-restart-notice-lifecycle.test.ts": 48, + "src/renderer/src/store/slices/degraded-repo-hydration.test.ts": 81, + "src/renderer/src/store/slices/detected-agents-environment-prune-leak.test.ts": 55, + "src/renderer/src/store/slices/detected-agents.test.ts": 138, + "src/renderer/src/store/slices/detected-worktree-refresh-leases.test.ts": 11, + "src/renderer/src/store/slices/dictation-model-state-stabilisation.test.ts": 9, + "src/renderer/src/store/slices/diffComments.test.ts": 122, + "src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts": 62, + "src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts": 39, + "src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts": 147, + "src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts": 11, + "src/renderer/src/store/slices/editor-branch-diff-snapshots.test.ts": 19, + "src/renderer/src/store/slices/editor-branch-line-total.test.ts": 16, + "src/renderer/src/store/slices/editor-check-details-tabs.test.ts": 27, + "src/renderer/src/store/slices/editor-close-file-cleanup.test.ts": 255, + "src/renderer/src/store/slices/editor-git-status-reconciliation.test.ts": 31, + "src/renderer/src/store/slices/editor-hydration-scaling.test.ts": 85, + "src/renderer/src/store/slices/editor-markdown-link-activation.test.ts": 38, + "src/renderer/src/store/slices/editor-markdown-view-state.test.ts": 37, + "src/renderer/src/store/slices/editor-open-diff.test.ts": 42, + "src/renderer/src/store/slices/editor-read-only-tabs.test.ts": 16, + "src/renderer/src/store/slices/editor-recently-closed-tabs.test.ts": 40, + "src/renderer/src/store/slices/editor-rekey-open-files.test.ts": 75, + "src/renderer/src/store/slices/editor-remote-branch-actions.test.ts": 62, + "src/renderer/src/store/slices/editor-rich-markdown-size-override.test.ts": 14, + "src/renderer/src/store/slices/editor-right-sidebar-state.test.ts": 24, + "src/renderer/src/store/slices/editor-session-duplicate-restore.test.ts": 84, + "src/renderer/src/store/slices/editor-state-worktree-purge-leak.test.ts": 59, + "src/renderer/src/store/slices/editor-tab-placement.test.ts": 30, + "src/renderer/src/store/slices/editor/actions/markdown-preview-actions.test.ts": 9, + "src/renderer/src/store/slices/editor/file-ids/hydrated-editor-file-index.test.ts": 512, + "src/renderer/src/store/slices/editor/file-ids/hydrated-editor-projections.test.ts": 143, + "src/renderer/src/store/slices/editor/git/git-status-reconciliation.perf.test.ts": 36, + "src/renderer/src/store/slices/folder-workspace-activation-and-activity.test.ts": 144, + "src/renderer/src/store/slices/folder-workspace-diff-comments.test.ts": 388, + "src/renderer/src/store/slices/folder-workspace-owner-routed-mutations.test.ts": 188, + "src/renderer/src/store/slices/generation-records-worktree-removal-leak.test.ts": 75, + "src/renderer/src/store/slices/github-branch-mismatched-linked-pr.test.ts": 5, + "src/renderer/src/store/slices/github-cache-eviction-and-bounds.test.ts": 221, + "src/renderer/src/store/slices/github-checks-cache.test.ts": 46, + "src/renderer/src/store/slices/github-checks.test.ts": 9, + "src/renderer/src/store/slices/github-issue-source-indicator-suppression.test.ts": 45, + "src/renderer/src/store/slices/github-issue-state-machine.test.ts": 35, + "src/renderer/src/store/slices/github-pr-branch-coordinator-events.test.ts": 35, + "src/renderer/src/store/slices/github-pr-branch-direct-refresh-scope.test.ts": 20, + "src/renderer/src/store/slices/github-pr-branch-fallback-results.test.ts": 26, + "src/renderer/src/store/slices/github-pr-branch-hosted-review-cache.test.ts": 27, + "src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts": 33, + "src/renderer/src/store/slices/github-pr-checks-fetch.test.ts": 121, + "src/renderer/src/store/slices/github-pr-comments.test.ts": 89, + "src/renderer/src/store/slices/github-pr-refresh-host-guard.test.ts": 28, + "src/renderer/src/store/slices/github-pr-refresh-hosted-review-cache-leak.test.ts": 802, + "src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts": 218, + "src/renderer/src/store/slices/github-pr-refresh-sequences-leak.test.ts": 16, + "src/renderer/src/store/slices/github-pr-refresh-states-leak.test.ts": 4478, + "src/renderer/src/store/slices/github-project-request-coordination.test.ts": 20, + "src/renderer/src/store/slices/github-project-row-owner.test.ts": 6, + "src/renderer/src/store/slices/github-project-view-tables.test.ts": 60, + "src/renderer/src/store/slices/github-provider-request-concurrency.test.ts": 19, + "src/renderer/src/store/slices/github-refresh-sweep.test.ts": 252, + "src/renderer/src/store/slices/github-repo-lookup-index.test.ts": 4, + "src/renderer/src/store/slices/github-review-thread-actions.test.ts": 24, + "src/renderer/src/store/slices/github-work-item-cache-identity.test.ts": 13, + "src/renderer/src/store/slices/github-work-items-error-envelope.test.ts": 43, + "src/renderer/src/store/slices/github-work-items-pagination.test.ts": 29, + "src/renderer/src/store/slices/github-work-items-query-bounds.test.ts": 10, + "src/renderer/src/store/slices/github-work-items-runtime-routing.test.ts": 218, + "src/renderer/src/store/slices/github-worktree-refresh-if-stale.test.ts": 85, + "src/renderer/src/store/slices/hosted-review-cache-race.test.ts": 15, + "src/renderer/src/store/slices/hosted-review-cache.test.ts": 69, + "src/renderer/src/store/slices/hosted-review.test.ts": 20, + "src/renderer/src/store/slices/jira.test.ts": 41, + "src/renderer/src/store/slices/linear-credential-error-recovery.test.ts": 31, + "src/renderer/src/store/slices/linear-invalidation.test.ts": 23, + "src/renderer/src/store/slices/linear-issue-cache-refresh.test.ts": 17, + "src/renderer/src/store/slices/linear-scoped-collection-cache.test.ts": 40, + "src/renderer/src/store/slices/linear-source-context-cache-scope.test.ts": 20, + "src/renderer/src/store/slices/linear.test.ts": 13, + "src/renderer/src/store/slices/local-detected-agent-state.test.ts": 12, + "src/renderer/src/store/slices/memory.test.ts": 7, + "src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts": 84, + "src/renderer/src/store/slices/new-issue-draft.test.ts": 7, + "src/renderer/src/store/slices/new-markdown.test.ts": 23, + "src/renderer/src/store/slices/orca-profiles-auth-actions.test.ts": 80, + "src/renderer/src/store/slices/orca-profiles.test.ts": 89, + "src/renderer/src/store/slices/pane-column-split-drop-no-op.test.ts": 8, + "src/renderer/src/store/slices/pane-foreground-agent.test.ts": 57, + "src/renderer/src/store/slices/persisted-ui-write-baseline.test.ts": 9, + "src/renderer/src/store/slices/pinned-tab-close-confirm.test.ts": 9, + "src/renderer/src/store/slices/preflight.test.ts": 26, + "src/renderer/src/store/slices/project-group-removal-targets.test.ts": 8, + "src/renderer/src/store/slices/purge-stale-runtime-host-ownership.test.ts": 50, + "src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts": 137, + "src/renderer/src/store/slices/rate-limits.test.ts": 7, + "src/renderer/src/store/slices/readopted-ssh-worktree-rows.test.ts": 6, + "src/renderer/src/store/slices/recently-closed-tabs.test.ts": 211, + "src/renderer/src/store/slices/remote-server-updates.integration.test.ts": 15, + "src/renderer/src/store/slices/repo-identity-reconcile.test.ts": 12, + "src/renderer/src/store/slices/repo-owner-cache-identity.test.ts": 5, + "src/renderer/src/store/slices/repo-reorder-host-split.test.ts": 5, + "src/renderer/src/store/slices/repos-add-races.test.ts": 56, + "src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts": 76, + "src/renderer/src/store/slices/repos-all-hosts-generation.test.ts": 114, + "src/renderer/src/store/slices/repos-all-hosts.test.ts": 148, + "src/renderer/src/store/slices/repos-catalog-merge-identity.test.ts": 88, + "src/renderer/src/store/slices/repos-cross-host-project-collisions.test.ts": 117, + "src/renderer/src/store/slices/repos-cross-host-refresh-identity.test.ts": 91, + "src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts": 32, + "src/renderer/src/store/slices/repos-host-identity-routing.test.ts": 191, + "src/renderer/src/store/slices/repos-manual-order-hydration.test.ts": 172, + "src/renderer/src/store/slices/repos-module-lifetime-coordinators.test.ts": 71, + "src/renderer/src/store/slices/repos-nested-import-refresh-failures.test.ts": 57, + "src/renderer/src/store/slices/repos-nested-ssh-projection.test.ts": 52, + "src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts": 349, + "src/renderer/src/store/slices/repos-paired-runtime-add.test.ts": 46, + "src/renderer/src/store/slices/repos-project-group-create-race.test.ts": 101, + "src/renderer/src/store/slices/repos-project-groups-delete.test.ts": 86, + "src/renderer/src/store/slices/repos-project-groups-owner-routing.test.ts": 73, + "src/renderer/src/store/slices/repos-project-groups.test.ts": 200, + "src/renderer/src/store/slices/repos-project-host-capability.test.ts": 93, + "src/renderer/src/store/slices/repos-project-host-lifecycle.test.ts": 50, + "src/renderer/src/store/slices/repos-project-runtime.test.ts": 77, + "src/renderer/src/store/slices/repos-refresh-identity.test.ts": 128, + "src/renderer/src/store/slices/repos-remove-missing-remote-project.test.ts": 57, + "src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts": 87, + "src/renderer/src/store/slices/repos-runtime-project-groups.test.ts": 56, + "src/renderer/src/store/slices/repos-runtime-visibility-defaults.test.ts": 43, + "src/renderer/src/store/slices/repos-selected-owner-routing.test.ts": 194, + "src/renderer/src/store/slices/repos-setup-script-dismissals.test.ts": 52, + "src/renderer/src/store/slices/repos-shared-project-badge-color.test.ts": 67, + "src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts": 80, + "src/renderer/src/store/slices/repos-stale-fetch.test.ts": 93, + "src/renderer/src/store/slices/repos-update-serialization.test.ts": 52, + "src/renderer/src/store/slices/repos.runtime-fallback.test.ts": 73, + "src/renderer/src/store/slices/repos.test.ts": 185, + "src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts": 106, + "src/renderer/src/store/slices/runtime-catalog-merge-removed-env-guard.test.ts": 45, + "src/renderer/src/store/slices/runtime-environment-ssh.test.ts": 93, + "src/renderer/src/store/slices/runtime-host-purge-session-partition-split.test.ts": 50, + "src/renderer/src/store/slices/runtime-pane-title-sort-epoch.test.ts": 189, + "src/renderer/src/store/slices/runtime-status-catalog-identity.test.ts": 94, + "src/renderer/src/store/slices/runtime-status-first-publication.test.ts": 6, + "src/renderer/src/store/slices/runtime-status-refresh-diagnostics.test.ts": 7, + "src/renderer/src/store/slices/runtime-status-restored-browser-host-attach.test.ts": 9, + "src/renderer/src/store/slices/runtime-status-snapshot.test.ts": 8, + "src/renderer/src/store/slices/runtime-status.test.ts": 187, + "src/renderer/src/store/slices/runtime-switch-settings-persistence.test.ts": 37, + "src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts": 13, + "src/renderer/src/store/slices/set-runtime-environments-purge-wiring.test.ts": 46, + "src/renderer/src/store/slices/settings-owner-hydration-write-fence.test.ts": 211, + "src/renderer/src/store/slices/settings-search-state.test.ts": 8, + "src/renderer/src/store/slices/settings.test.ts": 338, + "src/renderer/src/store/slices/sparse-presets-repo-removal-purge-leak.test.ts": 78, + "src/renderer/src/store/slices/sparse-presets.test.ts": 26, + "src/renderer/src/store/slices/ssh.test.ts": 89, + "src/renderer/src/store/slices/stale-runtime-host-rows.test.ts": 15, + "src/renderer/src/store/slices/store-active-worktree-selection.test.ts": 87, + "src/renderer/src/store/slices/store-active-worktree-split-groups.test.ts": 94, + "src/renderer/src/store/slices/store-active-worktree-tab-close.test.ts": 159, + "src/renderer/src/store/slices/store-active-worktree-terminal-creation.test.ts": 88, + "src/renderer/src/store/slices/store-create-tab-id-hint.test.ts": 57, + "src/renderer/src/store/slices/store-session-browser-hydration.test.ts": 117, + "src/renderer/src/store/slices/store-session-cascades.test.ts": 86, + "src/renderer/src/store/slices/store-session-editor-hydration.test.ts": 111, + "src/renderer/src/store/slices/store-session-terminal-activity.test.ts": 169, + "src/renderer/src/store/slices/store-session-terminal-reconnect.test.ts": 78, + "src/renderer/src/store/slices/store-session-workspace-hydration.test.ts": 84, + "src/renderer/src/store/slices/store-sleep-agent-status-retention.test.ts": 122, + "src/renderer/src/store/slices/store-sleep-exact-runtime-stop.test.ts": 147, + "src/renderer/src/store/slices/store-sleep-pane-hibernation.test.ts": 96, + "src/renderer/src/store/slices/store-sleep-runtime-convergence.test.ts": 448, + "src/renderer/src/store/slices/store-sleep-shutdown-order.test.ts": 49, + "src/renderer/src/store/slices/store-worktree-removal-cascade.test.ts": 141, + "src/renderer/src/store/slices/superseded-ssh-repo-rows.test.ts": 7, + "src/renderer/src/store/slices/tab-group-reference-repair.test.ts": 15, + "src/renderer/src/store/slices/tab-group-state.test.ts": 19, + "src/renderer/src/store/slices/tab-view-mode.test.ts": 86, + "src/renderer/src/store/slices/tab-worktree-orphan-map-purge-leak.test.ts": 27, + "src/renderer/src/store/slices/tabs-empty-split-activation.test.ts": 29, + "src/renderer/src/store/slices/tabs-hydration-generated-title.test.ts": 12, + "src/renderer/src/store/slices/tabs-hydration-group-validation.test.ts": 15, + "src/renderer/src/store/slices/tabs-hydration.test.ts": 12, + "src/renderer/src/store/slices/tabs-label-and-pin-state.test.ts": 68, + "src/renderer/src/store/slices/tabs-model-reconciliation.test.ts": 83, + "src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts": 185, + "src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts": 88, + "src/renderer/src/store/slices/tabs-session-hydration.test.ts": 67, + "src/renderer/src/store/slices/tabs-unread-and-focus.test.ts": 71, + "src/renderer/src/store/slices/tabs/hydrated-workspace-reconciliation-batch.test.ts": 423, + "src/renderer/src/store/slices/tabs/tab-selection-contract.test.ts": 12, + "src/renderer/src/store/slices/tabs/tabs-reconciliation-batch-identity.test.ts": 49, + "src/renderer/src/store/slices/task-creation-drafts.test.ts": 6, + "src/renderer/src/store/slices/terminal-helpers.test.ts": 11, + "src/renderer/src/store/slices/terminal-input-activity-store-write.test.ts": 51, + "src/renderer/src/store/slices/terminal-layout-pty-ownership.test.ts": 63, + "src/renderer/src/store/slices/terminal-orphan-helpers.test.ts": 8, + "src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts": 33, + "src/renderer/src/store/slices/terminal-pane-detach-agent-retention.test.ts": 92, + "src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts": 36, + "src/renderer/src/store/slices/terminal-quick-command-hosts.test.ts": 132, + "src/renderer/src/store/slices/terminal-startup-command-retention.test.ts": 80, + "src/renderer/src/store/slices/terminal-tab-id-hydration.test.ts": 38, + "src/renderer/src/store/slices/terminal-tab-owner-index.test.ts": 6, + "src/renderer/src/store/slices/terminal-tab-recovery-remount.test.ts": 151, + "src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts": 172, + "src/renderer/src/store/slices/terminal-tab-retirement.test.ts": 15, + "src/renderer/src/store/slices/terminal-tab-title-batch.test.ts": 147, + "src/renderer/src/store/slices/terminal-write-identity-bailouts.test.ts": 85, + "src/renderer/src/store/slices/terminals-explicit-empty-hydration.test.ts": 61, + "src/renderer/src/store/slices/terminals-hydration-canonical-pty-overlap.test.ts": 87, + "src/renderer/src/store/slices/terminals-hydration-canonical-rows.test.ts": 76, + "src/renderer/src/store/slices/terminals-hydration.test.ts": 145, + "src/renderer/src/store/slices/ui-acknowledge-agents-clock-skew.test.ts": 17, + "src/renderer/src/store/slices/ui-agent-send-target.test.ts": 58, + "src/renderer/src/store/slices/ui-contextual-tours.test.ts": 51, + "src/renderer/src/store/slices/ui-feature-interactions.test.ts": 31, + "src/renderer/src/store/slices/ui-hydration-view-layout.test.ts": 46, + "src/renderer/src/store/slices/ui-hydration-workspace-cleanup-browse.test.ts": 11, + "src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts": 45, + "src/renderer/src/store/slices/ui-modal-slot-dismissal.test.ts": 184, + "src/renderer/src/store/slices/ui-new-workspace-draft.test.ts": 8, + "src/renderer/src/store/slices/ui-notice-dismissals.test.ts": 52, + "src/renderer/src/store/slices/ui-page-navigation.test.ts": 51, + "src/renderer/src/store/slices/usage-snapshot-refresh.benchmark.test.ts": 6, + "src/renderer/src/store/slices/usage-web-client-fallback.test.ts": 12, + "src/renderer/src/store/slices/workspace-cleanup-browse.test.ts": 21, + "src/renderer/src/store/slices/workspace-cleanup-cache-hydration.test.ts": 174, + "src/renderer/src/store/slices/workspace-cleanup-enrichment-performance.test.ts": 285, + "src/renderer/src/store/slices/workspace-cleanup-host-qualified-list-state.test.ts": 71, + "src/renderer/src/store/slices/workspace-cleanup-local-evidence-invariants.test.ts": 7, + "src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts": 62, + "src/renderer/src/store/slices/workspace-cleanup-scan-progress-boundary.test.ts": 5, + "src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts": 584, + "src/renderer/src/store/slices/workspace-cleanup-unverified-removal-consent.test.ts": 44, + "src/renderer/src/store/slices/workspace-cleanup-wrong-host-removal-guard.test.ts": 99, + "src/renderer/src/store/slices/workspace-cleanup-wrong-host-removal.test.ts": 143, + "src/renderer/src/store/slices/workspace-document-title-refresh.test.ts": 48, + "src/renderer/src/store/slices/workspace-space.test.ts": 11, + "src/renderer/src/store/slices/worktree-by-id-index.test.ts": 6, + "src/renderer/src/store/slices/worktree-catalog-reconciliation.test.ts": 12, + "src/renderer/src/store/slices/worktree-helpers.test.ts": 6, + "src/renderer/src/store/slices/worktree-listing-branch-switch.test.ts": 11, + "src/renderer/src/store/slices/worktree-meta-update-application.test.ts": 8, + "src/renderer/src/store/slices/worktree-nav-history-view-entries.test.ts": 21, + "src/renderer/src/store/slices/worktree-nav-history.test.ts": 12, + "src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts": 91, + "src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts": 78, + "src/renderer/src/store/slices/worktree-terminal-removal-teardown.test.ts": 79, + "src/renderer/src/store/slices/worktree-visibility-owner-settings.test.ts": 14, + "src/renderer/src/store/slices/worktree-visibility-settings-write.test.ts": 32, + "src/renderer/src/store/slices/worktrees-activity-persistence.test.ts": 66, + "src/renderer/src/store/slices/worktrees-create-base-status.test.ts": 33, + "src/renderer/src/store/slices/worktrees-create-parent-pick.test.ts": 27, + "src/renderer/src/store/slices/worktrees-fetch-listing-merge.test.ts": 42, + "src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts": 173, + "src/renderer/src/store/slices/worktrees-fetch-persisted-metadata-fallback.test.ts": 25, + "src/renderer/src/store/slices/worktrees-fetch-refresh-coalescing.test.ts": 40, + "src/renderer/src/store/slices/worktrees-fetch-remote-lineage.test.ts": 25, + "src/renderer/src/store/slices/worktrees-fetch-removal-purge.test.ts": 87, + "src/renderer/src/store/slices/worktrees-git-identity-branch-title.test.ts": 17, + "src/renderer/src/store/slices/worktrees-git-identity-review-clear-persistence.test.ts": 488, + "src/renderer/src/store/slices/worktrees-hydration-purge.test.ts": 59, + "src/renderer/src/store/slices/worktrees-identity-migration.test.ts": 292, + "src/renderer/src/store/slices/worktrees-lineage-state.test.ts": 41, + "src/renderer/src/store/slices/worktrees-linked-review-push-target.test.ts": 69, + "src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts": 43, + "src/renderer/src/store/slices/worktrees-pending-creation-state.test.ts": 70, + "src/renderer/src/store/slices/worktrees-purge-terminal-state.test.ts": 11, + "src/renderer/src/store/slices/worktrees-remote-runtime-create.test.ts": 35, + "src/renderer/src/store/slices/worktrees-remote-runtime-removal.test.ts": 49, + "src/renderer/src/store/slices/worktrees-removal-state-cleanup.test.ts": 60, + "src/renderer/src/store/slices/worktrees-runtime-connection-generation-fence.test.ts": 81, + "src/renderer/src/store/slices/worktrees-runtime-host-metadata-retirement.test.ts": 25, + "src/renderer/src/store/slices/worktrees-terminal-pr-url-linking.test.ts": 15, + "src/renderer/src/store/slices/worktrees-unread-state.test.ts": 19, + "src/renderer/src/store/slices/worktrees-workspace-selection-state.test.ts": 27, + "src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.test.ts": 8, + "src/renderer/src/store/slices/worktrees/listing/detected-worktree-unavailable-reason.test.ts": 7, + "src/renderer/src/store/slices/worktrees/metadata/worktree-meta-persist.test.ts": 14, + "src/renderer/src/store/slices/worktrees/teardown/host-qualified-removal-refusal.test.ts": 6, + "src/renderer/src/store/slices/worktrees/teardown/record-key-omission.test.ts": 7, + "src/renderer/src/store/slices/worktrees/teardown/remove-worktree-map-identity.test.ts": 9, + "src/renderer/src/store/slices/worktrees/teardown/worktree-teardown-array-identity.test.ts": 9, + "src/renderer/src/store/store-identity-churn-probe.test.ts": 19, + "src/renderer/src/store/store-listener-census.test.ts": 7, + "src/renderer/src/store/terminals/restored-relay-session-identity.test.ts": 88, + "src/renderer/src/store/terminals/structured-chat-tab-rename.test.ts": 68, + "src/renderer/src/store/terminals/terminal-pane-expansion-write-bailout.test.tsx": 113, + "src/renderer/src/store/terminals/terminal-shutdown-guards-identity.test.ts": 5, + "src/renderer/src/store/terminals/terminal-shutdown-map-identity.test.ts": 7, + "src/renderer/src/store/terminals/terminal-tab-close-map-identity.test.ts": 68, + "src/renderer/src/store/terminals/terminal-tab-close-tombstone.test.ts": 86, + "src/renderer/src/store/terminals/terminal-workspace-routing.scale.test.ts": 9, + "src/renderer/src/store/terminals/workspace-terminal-placeholders.test.ts": 10, + "src/renderer/src/store/worktree-diff-comments-selector.test.ts": 53, + "src/renderer/src/store/worktree-host-collision-index.test.ts": 7, + "src/renderer/src/store/worktree-visibility-defaults-by-host.test.ts": 5, + "src/renderer/src/web/preload-api/web-gitlab-api.test.ts": 23, + "src/renderer/src/web/web-clipboard-copy-fallback.test.ts": 12, + "src/renderer/src/web/web-clipboard-copy-terminal-selection.test.ts": 12, + "src/renderer/src/web/web-file-mutation-methods.test.ts": 14, + "src/renderer/src/web/web-pairing.test.ts": 11, + "src/renderer/src/web/web-preload-api-agent-providers.test.ts": 341, + "src/renderer/src/web/web-preload-api-clipboard.test.ts": 1137, + "src/renderer/src/web/web-preload-api-composition.test.ts": 443, + "src/renderer/src/web/web-preload-api-filesystem.test.ts": 413, + "src/renderer/src/web/web-preload-api-git.test.ts": 391, + "src/renderer/src/web/web-preload-api-github.test.ts": 312, + "src/renderer/src/web/web-preload-api-gitlab.test.ts": 596, + "src/renderer/src/web/web-preload-api-keybindings.test.ts": 444, + "src/renderer/src/web/web-preload-api-runtime-calls.test.ts": 755, + "src/renderer/src/web/web-preload-api-runtime-environment.test.ts": 1195, + "src/renderer/src/web/web-preload-api-settings.test.ts": 1562, + "src/renderer/src/web/web-preload-api-ssh.test.ts": 640, + "src/renderer/src/web/web-preload-api-ui.test.ts": 901, + "src/renderer/src/web/web-preload-api-workspace-catalog.test.ts": 903, + "src/renderer/src/web/web-preload-updater-package-recovery.test.ts": 370, + "src/renderer/src/web/web-runtime-client-export-parity.test.ts": 6, + "src/renderer/src/web/web-runtime-client-file-watch-replay.test.ts": 64, + "src/renderer/src/web/web-runtime-client-heartbeat.test.ts": 42, + "src/renderer/src/web/web-runtime-client-timeout-budget.test.ts": 45, + "src/renderer/src/web/web-runtime-client.test.ts": 351, + "src/renderer/src/web/web-runtime-connection-frame-router.test.ts": 6, + "src/renderer/src/web/web-runtime-connection-heartbeat-unsendable-probe.test.ts": 55, + "src/renderer/src/web/web-runtime-status-owner.test.ts": 651, + "src/renderer/src/web/web-viewport-shell.test.ts": 8, + "src/renderer/src/web/web-workspace-session.test.ts": 8, + "src/shared/add-repo-existing-workspaces-telemetry.test.ts": 9, + "src/shared/agent-cli-flag-detection.test.ts": 7, + "src/shared/agent-cli-install-dir-fallback.test.ts": 26, + "src/shared/agent-decorative-title-signature.test.ts": 11, + "src/shared/agent-detection.test.ts": 57, + "src/shared/agent-feature-install-commands.test.ts": 13, + "src/shared/agent-hook-endpoint-file.test.ts": 5, + "src/shared/agent-hook-endpoint-temp-cleanup.test.ts": 14, + "src/shared/agent-hook-listener-antigravity.test.ts": 17, + "src/shared/agent-hook-listener-claude-compatible-vendors.test.ts": 20, + "src/shared/agent-hook-listener-claude-subagents.test.ts": 21, + "src/shared/agent-hook-listener-claude-turn-state.test.ts": 19, + "src/shared/agent-hook-listener-command-code-transcript.test.ts": 114, + "src/shared/agent-hook-listener-extraction-characterization.test.ts": 23, + "src/shared/agent-hook-listener-grok.test.ts": 32, + "src/shared/agent-hook-listener-hermes-codex-droid.test.ts": 13, + "src/shared/agent-hook-listener-interactive-prompts.test.ts": 17, + "src/shared/agent-hook-listener-pi-compatible.test.ts": 15, + "src/shared/agent-hook-listener-relay-dependency.test.ts": 22, + "src/shared/agent-hook-listener-roster-retention.test.ts": 54, + "src/shared/agent-hook-listener-session-replacement.test.ts": 15, + "src/shared/agent-hook-listener-startless-child-lifecycle.test.ts": 22, + "src/shared/agent-hook-listener-transport.test.ts": 49, + "src/shared/agent-hook-listener/transcript-reader.test.ts": 93, + "src/shared/agent-hook-relay.test.ts": 9, + "src/shared/agent-hook-request-body-memory.test.ts": 53, + "src/shared/agent-hook-spool-read.test.ts": 9, + "src/shared/agent-hook-status-cache.test.ts": 5, + "src/shared/agent-hook-transport-interference.test.ts": 9, + "src/shared/agent-kind.test.ts": 9, + "src/shared/agent-launch-remote.test.ts": 7, + "src/shared/agent-model-probe-spec.test.ts": 9, + "src/shared/agent-notification-id.test.ts": 8, + "src/shared/agent-process-recognition.test.ts": 20, + "src/shared/agent-prompt-injection.test.ts": 7, + "src/shared/agent-resume-argv-drop.test.ts": 11, + "src/shared/agent-resume-launch-command.test.ts": 63, + "src/shared/agent-row-conversation-name.test.ts": 10, + "src/shared/agent-scratch-worktrees.test.ts": 7, + "src/shared/agent-session-conversation-name.test.ts": 9, + "src/shared/agent-session-definitive-refusal.test.ts": 11, + "src/shared/agent-session-journal-schemas.test.ts": 35, + "src/shared/agent-session-lease-adjudication.test.ts": 16, + "src/shared/agent-session-mutation-envelope.test.ts": 10, + "src/shared/agent-session-operation-ledger.test.ts": 9, + "src/shared/agent-session-option-catalog-grok.test.ts": 12, + "src/shared/agent-session-option-catalog.test.ts": 10, + "src/shared/agent-session-provider-handle.test.ts": 21, + "src/shared/agent-session-pty-write-admission.test.ts": 8, + "src/shared/agent-session-question-answer.test.ts": 9, + "src/shared/agent-session-resume.test.ts": 9, + "src/shared/agent-session-turn-record.test.ts": 9, + "src/shared/agent-skill-sharing-contract.test.ts": 9, + "src/shared/agent-skill-sharing-gate.test.ts": 6, + "src/shared/agent-status-observation.test.ts": 13, + "src/shared/agent-status-osc-pending-retention.test.ts": 73, + "src/shared/agent-status-osc-scan-budget.test.ts": 195, + "src/shared/agent-status-osc-split-frame-scan-budget.test.ts": 96, + "src/shared/agent-status-osc.test.ts": 16, + "src/shared/agent-status-types.test.ts": 29, + "src/shared/agent-tab-title.test.ts": 14, + "src/shared/agent-terminal-status-equivalence.test.ts": 4, + "src/shared/agent-title-agy-gemini-collision.test.ts": 23, + "src/shared/agent-title-decoration.test.ts": 9, + "src/shared/agent-title-evidence.test.ts": 43, + "src/shared/agent-title-identity-characterization.test.ts": 11, + "src/shared/agent-tui-input-clear.test.ts": 13, + "src/shared/ai-vault-resume-command.test.ts": 7, + "src/shared/ai-vault-resume-preparation.test.ts": 5, + "src/shared/ai-vault-scan-error-message.test.ts": 14, + "src/shared/ai-vault-search-query-operators.test.ts": 10, + "src/shared/ai-vault-session-depth.test.ts": 17, + "src/shared/ai-vault-session-filters.test.ts": 172, + "src/shared/ai-vault-types.test.ts": 9, + "src/shared/app-version.test.ts": 12, + "src/shared/artifact-sharing-gate.test.ts": 9, + "src/shared/automation-execution-target.test.ts": 11, + "src/shared/automation-host-filter.test.ts": 5, + "src/shared/automation-legacy-list-partition.test.ts": 8, + "src/shared/automation-list-response.test.ts": 10, + "src/shared/automation-list-scope.test.ts": 19, + "src/shared/automation-owner-key.test.ts": 10, + "src/shared/automation-owner-precondition.test.ts": 11, + "src/shared/automation-run-identity.test.ts": 5, + "src/shared/automation-run-retention.test.ts": 19, + "src/shared/automation-schedules.test.ts": 183, + "src/shared/automation-usage-summary.test.ts": 9, + "src/shared/base-ref-search-result.test.ts": 6, + "src/shared/binary-file-extensions.test.ts": 4, + "src/shared/bounded-map.test.ts": 17, + "src/shared/branch-name-from-work.test.ts": 11, + "src/shared/branch-prefix.test.ts": 12, + "src/shared/browser-client-automation-protocol.test.ts": 12, + "src/shared/browser-client-host-id-argument.test.ts": 6, + "src/shared/browser-client-host-protocol.test.ts": 241, + "src/shared/browser-client-host-reconciliation-protocol.test.ts": 22, + "src/shared/browser-client-hosting-eligibility.test.ts": 9, + "src/shared/browser-cookie-import-sources.test.ts": 5, + "src/shared/browser-grab-types.test.ts": 11, + "src/shared/browser-network-capabilities.test.ts": 10, + "src/shared/browser-network-tunnel-protocol.test.ts": 11, + "src/shared/browser-network-tunnel-stream-framing.test.ts": 1908, + "src/shared/browser-screencast-protocol.test.ts": 7, + "src/shared/browser-url.test.ts": 14, + "src/shared/cheap-process-table-snapshot.test.ts": 10, + "src/shared/check-job-log-tail-slice.test.ts": 7, + "src/shared/child-process/child-process-import-boundary.test.ts": 9, + "src/shared/child-process/close-process-registry.test.ts": 8, + "src/shared/child-process/process-tree-termination.test.ts": 16, + "src/shared/child-process/retryable-process-exit-proof.test.ts": 8, + "src/shared/child-process/run-process-termination-failure.test.ts": 12, + "src/shared/child-process/run-process.test.ts": 7159, + "src/shared/child-process/windows-cmd-shim-resolution.test.ts": 15, + "src/shared/child-process/windows-command-line.test.ts": 14, + "src/shared/child-process/windows-console-visibility.test.ts": 7, + "src/shared/claimed-agent-pty-owner.test.ts": 33, + "src/shared/claude-agent-teams-tmux-compat.test.ts": 8, + "src/shared/claude-background-task-status.test.ts": 36, + "src/shared/claude-model-list-probe.test.ts": 14, + "src/shared/claude-statusline-rate-limits.test.ts": 7, + "src/shared/claude-subagent-roster.test.ts": 17, + "src/shared/claude-subagent-row-lifecycle.test.ts": 14, + "src/shared/cli-argument-boundary.test.ts": 5, + "src/shared/cli-runtime-pairing-boundary.test.ts": 182, + "src/shared/cli-workspace-provenance.test.ts": 6, + "src/shared/client-environment-info.test.ts": 10, + "src/shared/clipboard-image.test.ts": 9, + "src/shared/clipboard-text.test.ts": 29, + "src/shared/closed-terminal-tab-tombstones.test.ts": 12, + "src/shared/cloud-service-url.test.ts": 4, + "src/shared/codex-auth-errors.test.ts": 22, + "src/shared/codex-pet-sprite-defaults.test.ts": 12, + "src/shared/codex-reset-credit-attempt-ledger.test.ts": 16, + "src/shared/codex-reset-credit-scope.test.ts": 8, + "src/shared/codex-startup-delivery.test.ts": 8, + "src/shared/codex-subagent-poll-scheduler.test.ts": 14, + "src/shared/codex-subagent-rollout-lifecycle.test.ts": 18, + "src/shared/codex-subagent-roster.test.ts": 6, + "src/shared/codex-subagent-transcript.test.ts": 18, + "src/shared/combined-diff-file-tree-width.test.ts": 6, + "src/shared/command-code-output-status.test.ts": 155, + "src/shared/command-code-turn-boundary.test.ts": 6, + "src/shared/command-token-scanner.test.ts": 11, + "src/shared/commit-message-agent-spec.test.ts": 38, + "src/shared/commit-message-generation.test.ts": 12, + "src/shared/commit-message-plan.test.ts": 21, + "src/shared/commit-message-prompt.test.ts": 28, + "src/shared/computer-awake-mode.test.ts": 7, + "src/shared/computer-use-error-recovery.test.ts": 6, + "src/shared/computer-use-key-spec.test.ts": 7, + "src/shared/constants.test.ts": 15, + "src/shared/contextual-tours.test.ts": 16, + "src/shared/crash-reporting.test.ts": 18, + "src/shared/cross-platform-path-guards.test.ts": 1239, + "src/shared/cross-platform-path.test.ts": 17, + "src/shared/daemon-adoption-telemetry.test.ts": 10, + "src/shared/direct-ssh-reconnect-telemetry-schema.test.ts": 17, + "src/shared/doc-preview-file-access.test.ts": 36, + "src/shared/doc-preview-scheme.test.ts": 8, + "src/shared/draft-paste-ready-scanner-grok-trace-replay.test.ts": 7, + "src/shared/draft-paste-ready-scanner.test.ts": 19, + "src/shared/emoji-shortcode-catalog.lazy.test.ts": 42, + "src/shared/emulator-keyboard-frame.test.ts": 7, + "src/shared/emulator-touch-frame.test.ts": 5, + "src/shared/ephemeral-setup-terminal-worktree-id.test.ts": 6, + "src/shared/ephemeral-vm-recipe-checkout-mode.test.ts": 7, + "src/shared/ephemeral-vm-recipe-doctor.test.ts": 10, + "src/shared/ephemeral-vm-recipe-process.test.ts": 154, + "src/shared/ephemeral-vm-recipe-repo-url.test.ts": 7, + "src/shared/ephemeral-vm-recipes.test.ts": 23, + "src/shared/ephemeral-vm-runtime-feature-sorting.test.ts": 145, + "src/shared/ephemeral-vm-runtime-store-rollback.test.ts": 341, + "src/shared/ephemeral-vm-runtime-store.test.ts": 41, + "src/shared/event-loop-yield.test.ts": 28, + "src/shared/execution-host-registry.test.ts": 15, + "src/shared/execution-host.test.ts": 14, + "src/shared/export-let-function-initializer-ban.test.ts": 2085, + "src/shared/external-automation-jobs-file.test.ts": 48, + "src/shared/external-worktree-inbox.test.ts": 11, + "src/shared/feature-education-telemetry.test.ts": 5, + "src/shared/feature-interactions.test.ts": 1489, + "src/shared/feature-tips.test.ts": 7, + "src/shared/feature-wall-tour-depth.test.ts": 5, + "src/shared/file-link-location.test.ts": 7, + "src/shared/file-name-sort.test.ts": 6, + "src/shared/filesystem-directory-listing-limit.test.ts": 10, + "src/shared/fish-binary-requirement.test.ts": 14, + "src/shared/folder-workspace-execution-host.test.ts": 17, + "src/shared/folder-workspace-worktree.test.ts": 8, + "src/shared/folder-workspaces.test.ts": 6, + "src/shared/foreground-process-ancestry-parity.test.ts": 286, + "src/shared/foreground-process-selection.test.ts": 11, + "src/shared/foreground-wrapper-agent.test.ts": 7, + "src/shared/generated-code-path.test.ts": 10, + "src/shared/git-branch-cleanup.test.ts": 17, + "src/shared/git-branch-compare-head.test.ts": 6, + "src/shared/git-branch-line-total-soft-deadline.test.ts": 3529, + "src/shared/git-branch-line-total.test.ts": 39, + "src/shared/git-capability-cache.test.ts": 10, + "src/shared/git-check-ignore-stdio.test.ts": 8, + "src/shared/git-clone-failure-message.test.ts": 26, + "src/shared/git-config-snapshot-runner.test.ts": 13, + "src/shared/git-configured-branch-target.test.ts": 11, + "src/shared/git-cquoted-path.test.ts": 3, + "src/shared/git-diff-transport-budget.test.ts": 1152, + "src/shared/git-discard-path-safety.test.ts": 22, + "src/shared/git-exec-mutation.test.ts": 10, + "src/shared/git-fetch-head-capability.test.ts": 6, + "src/shared/git-fetch-head-lock-key-derivation.test.ts": 64, + "src/shared/git-fetch-head-lock.test.ts": 108, + "src/shared/git-fork-sync.test.ts": 21, + "src/shared/git-history-graph.test.ts": 10, + "src/shared/git-history-message-allocation.test.ts": 11, + "src/shared/git-history-ref-display.test.ts": 9, + "src/shared/git-history.test.ts": 18, + "src/shared/git-merge-tree-capability.test.ts": 9, + "src/shared/git-metadata-path.test.ts": 13, + "src/shared/git-push-target-validation.test.ts": 6, + "src/shared/git-remote-error.test.ts": 36, + "src/shared/git-remote-identity.test.ts": 10, + "src/shared/git-remote-url-index.test.ts": 11, + "src/shared/git-rev-list-output.test.ts": 8, + "src/shared/git-status-branch-line-total-cache.test.ts": 12, + "src/shared/git-status-conflict-entries.test.ts": 10, + "src/shared/git-status-limit.test.ts": 6, + "src/shared/git-status-line-stat-inputs.test.ts": 6, + "src/shared/git-status-line-stats-cache.test.ts": 10, + "src/shared/git-status-upstream-ref.test.ts": 6, + "src/shared/git-uncommitted-line-stats.test.ts": 54, + "src/shared/git-upstream-status.test.ts": 16, + "src/shared/git-worktree-operation-lock.test.ts": 16, + "src/shared/github/api-availability.test.ts": 6, + "src/shared/github/project-identity.test.ts": 5, + "src/shared/github/project-ref-input.test.ts": 11, + "src/shared/github/project-roadmap-timeline.test.ts": 11, + "src/shared/github/pull-request-auto-merge-availability.test.ts": 6, + "src/shared/github/pull-request-for-branch-outcome.test.ts": 7, + "src/shared/github/pull-request-merge-methods.test.ts": 8, + "src/shared/github/repository-identity-key.test.ts": 5, + "src/shared/github/work-items-query-bounds.test.ts": 9, + "src/shared/gitlab-job-log-excerpt.test.ts": 36, + "src/shared/gitlab-job-trace-check-details.test.ts": 17, + "src/shared/gitlab-pipeline-checks.test.ts": 6, + "src/shared/gitlab-projects.test.ts": 10, + "src/shared/grok-model-list-probe.test.ts": 14, + "src/shared/grok-session-paths.test.ts": 21, + "src/shared/growing-byte-buffer.test.ts": 89, + "src/shared/handled-wire-discriminant.test.ts": 4, + "src/shared/harness-injected-user-turns.test.ts": 8, + "src/shared/hermes-run-ref-retention.test.ts": 6, + "src/shared/hook-command-source-policy.test.ts": 12, + "src/shared/host-balanced-listing-scaling.test.ts": 13, + "src/shared/host-setting-overrides.test.ts": 10, + "src/shared/hosted-review-creation-providers.test.ts": 4, + "src/shared/hosted-review-github.test.ts": 8, + "src/shared/hosted-review-ready-capabilities.test.ts": 5, + "src/shared/hosted-review-refs.test.ts": 6, + "src/shared/image-data-uri.test.ts": 12, + "src/shared/image-paste-following-text.test.ts": 5, + "src/shared/in-flight-promise-dedupe.test.ts": 19, + "src/shared/issue-link-input.test.ts": 9, + "src/shared/jira-issue-url.test.ts": 8, + "src/shared/json-text-structure-limit.test.ts": 9, + "src/shared/keybindings-conflicts.test.ts": 32, + "src/shared/keybindings-default-bindings.test.ts": 32, + "src/shared/keybindings-digit-index.test.ts": 12, + "src/shared/keybindings-double-tap.test.ts": 14, + "src/shared/keybindings-keyboard-layout.test.ts": 8, + "src/shared/keybindings-parse-cache.test.ts": 43, + "src/shared/keybindings-parsing.test.ts": 12, + "src/shared/keybindings-terminal-context.test.ts": 7, + "src/shared/keybindings-unassigned-actions.test.ts": 12, + "src/shared/linear/inline-media.test.ts": 4, + "src/shared/linear/issue-attribute-filter.test.ts": 9, + "src/shared/linear/issue-view-resume-state.test.ts": 15, + "src/shared/linear/links.test.ts": 7, + "src/shared/linear/workspace-types.test.ts": 7, + "src/shared/linux-proc-port-scan-limits.test.ts": 12, + "src/shared/local-account-runtime.test.ts": 7, + "src/shared/local-build-compatibility.test.ts": 5, + "src/shared/localhost-worktree-labels.test.ts": 8, + "src/shared/loose-ref-count.test.ts": 247, + "src/shared/macos-symbolic-hotkeys.test.ts": 14, + "src/shared/managed-agent-command-token.test.ts": 9, + "src/shared/manual-repo-order.test.ts": 12, + "src/shared/map-settled-with-concurrency.test.ts": 20, + "src/shared/map-with-concurrency.test.ts": 58, + "src/shared/markdown-document-listing-limits.test.ts": 5, + "src/shared/markdown-toc-panel-width.test.ts": 7, + "src/shared/mcp-config.test.ts": 29, + "src/shared/mobile-e2ee-v2-contract.test.ts": 15, + "src/shared/mobile-e2ee-v2-framing.test.ts": 13, + "src/shared/mobile-file-directory-limit.test.ts": 11, + "src/shared/mobile-markdown-document.test.ts": 8, + "src/shared/mobile-pairing-connection-mode.test.ts": 9, + "src/shared/mobile-pairing-custom-address.test.ts": 13, + "src/shared/mobile-push-contract.test.ts": 5, + "src/shared/mobile-relay-close-codes.test.ts": 4, + "src/shared/mobile-relay-mint-failure.test.ts": 5, + "src/shared/mobile-relay-pairing-offer.test.ts": 22, + "src/shared/mobile-relay-phone-protocol.test.ts": 12, + "src/shared/model-id-label.test.ts": 4, + "src/shared/modifier-double-tap-detector.test.ts": 10, + "src/shared/native-chat-agent-profiles.test.ts": 17, + "src/shared/native-chat-agent-support.test.ts": 7, + "src/shared/native-chat-ask-fifo.test.ts": 186, + "src/shared/native-chat-ask.test.ts": 12, + "src/shared/native-chat-command-envelope.test.ts": 9, + "src/shared/native-chat-edit-normalize.test.ts": 35, + "src/shared/native-chat-href-routing.test.ts": 11, + "src/shared/native-chat-image-transcript-markers.test.ts": 19, + "src/shared/native-chat-session-option-commands.test.ts": 15, + "src/shared/native-chat-session-option-defaults.test.ts": 15, + "src/shared/native-chat-session-option-snapshot.test.ts": 15, + "src/shared/native-chat-session-option-state.test.ts": 10, + "src/shared/native-chat-slash-commands.test.ts": 31, + "src/shared/native-chat-stream-unsubscribe.test.ts": 3, + "src/shared/native-chat-streaming.test.ts": 9, + "src/shared/native-chat-subagent-summary.test.ts": 11, + "src/shared/native-chat-task-list.test.ts": 11, + "src/shared/native-chat-tool-activity.test.ts": 11, + "src/shared/native-chat-tool-attribution-allocation.test.ts": 20, + "src/shared/native-chat-tool-icon.test.ts": 19, + "src/shared/native-chat-tool-identity.test.ts": 14, + "src/shared/native-chat-tool-pair-limit.test.ts": 74, + "src/shared/native-chat-tool-summary.test.ts": 13, + "src/shared/native-chat-transcript-retention.test.ts": 5, + "src/shared/native-chat-turn-activity.test.ts": 9, + "src/shared/native-chat-turn-status.test.ts": 13, + "src/shared/native-chat-types.test.ts": 4, + "src/shared/native-chat-unverifiable-turn-status.test.ts": 8, + "src/shared/native-file-drop.test.ts": 28, + "src/shared/nested-repo-telemetry-schema.test.ts": 18, + "src/shared/nested-repo-telemetry.test.ts": 15, + "src/shared/nested-worker-depth.test.ts": 8, + "src/shared/network-proxy.test.ts": 9, + "src/shared/network/manual-address.test.ts": 13, + "src/shared/network/server-share-address.test.ts": 24, + "src/shared/new-workspace-dialog-repo.test.ts": 5, + "src/shared/new-workspace/smart-workspace-url-source-results.test.ts": 10, + "src/shared/new-workspace/work-item-lookup-text.test.ts": 8, + "src/shared/new-workspace/workspace-source.test.ts": 8, + "src/shared/new-workspace/worktree-create-retry-policy.test.ts": 7, + "src/shared/node-bounded-file-reader-sync.test.ts": 4, + "src/shared/node-bounded-file-reader.test.ts": 14, + "src/shared/node-bounded-json-stringify.test.ts": 24, + "src/shared/node-file-content-equality.test.ts": 15, + "src/shared/node-markdown-document-discovery.test.ts": 8, + "src/shared/node-pty-spawn-helper.test.ts": 7, + "src/shared/node-readable-text.test.ts": 14, + "src/shared/node-source-copy-content-equality.test.ts": 16, + "src/shared/nul-delimited-fields.test.ts": 5, + "src/shared/nvm-default-alias.test.ts": 38, + "src/shared/omp-pi-semantic-title-preservation.test.ts": 25, + "src/shared/onboarding-tour-telemetry-events.test.ts": 12, + "src/shared/open-in-applications.test.ts": 8, + "src/shared/opencode-permission-status.test.ts": 18, + "src/shared/opencode-terminal-title.test.ts": 4, + "src/shared/orca-yaml-alias-bounds.test.ts": 27, + "src/shared/orca-yaml-bounds.test.ts": 22, + "src/shared/orchestration-ask-timeout.test.ts": 5, + "src/shared/orchestration-check-output.test.ts": 7, + "src/shared/orchestration-compatibility-evidence.test.ts": 8, + "src/shared/orchestration-dispatch-refusal-contract.test.ts": 10, + "src/shared/orchestration-fleet-attention.test.ts": 6, + "src/shared/orchestration-fleet-evidence-clock.test.ts": 8, + "src/shared/orchestration-fleet-projection.test.ts": 12, + "src/shared/orchestration-rpc-contract.test.ts": 12, + "src/shared/orchestration-task-display.test.ts": 9, + "src/shared/orchestration-task-summary.test.ts": 9, + "src/shared/orchestration-timing-budgets.test.ts": 6, + "src/shared/osc-title-scan-tail-retention.test.ts": 34, + "src/shared/osc-title-scan-tail.test.ts": 4, + "src/shared/osc52-clipboard-settings.test.ts": 7, + "src/shared/own-retained-string.test.ts": 77, + "src/shared/pairing-address-auto-selection.test.ts": 5, + "src/shared/pairing-local-ui-fields.test.ts": 6, + "src/shared/pairing.test.ts": 14, + "src/shared/pane-agent-identity-adapter.test.ts": 21, + "src/shared/pane-agent-identity-inventory.test.ts": 782, + "src/shared/pane-agent-identity-resolver.test.ts": 15, + "src/shared/pane-agent-identity-surface-inventory.test.ts": 1226, + "src/shared/pane-agent-identity-title-corpus.test.ts": 17, + "src/shared/pane-agent-owner.test.ts": 8, + "src/shared/pane-key-alias.test.ts": 6, + "src/shared/physical-exit-tracker.test.ts": 11, + "src/shared/pi-agent-kind.test.ts": 4, + "src/shared/pi-overlay-ui-settings.test.ts": 7, + "src/shared/pi-state-title-marker.test.ts": 11, + "src/shared/plugins/plugin-consent-fingerprint.test.ts": 14, + "src/shared/plugins/plugin-consent-request.test.ts": 10, + "src/shared/plugins/plugin-content-pack-contributions.test.ts": 16, + "src/shared/plugins/plugin-demo-fixture.test.ts": 16, + "src/shared/plugins/plugin-hostile-fixture.test.ts": 4, + "src/shared/plugins/plugin-install-lockfile.test.ts": 10, + "src/shared/plugins/plugin-kill-list.test.ts": 47, + "src/shared/plugins/plugin-language-pack-artifact.test.ts": 134, + "src/shared/plugins/plugin-manifest.test.ts": 24, + "src/shared/plugins/plugin-marketplace.test.ts": 64, + "src/shared/plugins/plugin-panel-call-admission.test.ts": 6, + "src/shared/plugins/plugin-panel-message-budget.test.ts": 6, + "src/shared/plugins/plugin-panel-pong-frame.test.ts": 9, + "src/shared/plugins/plugin-panel-shell.test.ts": 7, + "src/shared/plugins/plugin-path-safety.test.ts": 15, + "src/shared/plugins/plugin-vm-recipe-artifact.test.ts": 18, + "src/shared/posix-wait-status.test.ts": 7, + "src/shared/powershell-native-argument.test.ts": 4, + "src/shared/pr-bot-author-overrides.test.ts": 11, + "src/shared/pr-check-severity-order.test.ts": 7, + "src/shared/pr-check-status.test.ts": 5, + "src/shared/pr-comment-audience.test.ts": 5, + "src/shared/pr-comment-groups.test.ts": 8, + "src/shared/pr-comment-time.test.ts": 7, + "src/shared/preferred-git-remote.test.ts": 6, + "src/shared/priority-semaphore.test.ts": 19, + "src/shared/process-output-field-scanner.test.ts": 6, + "src/shared/process-table-snapshot.test.ts": 72, + "src/shared/project-catalog-row-normalization.test.ts": 9, + "src/shared/project-execution-runtime.test.ts": 15, + "src/shared/project-groups.test.ts": 124, + "src/shared/project-host-setup-projection.test.ts": 30, + "src/shared/project-identity-succession.test.ts": 30, + "src/shared/promise-settlement-waiters.test.ts": 337, + "src/shared/protocol-compat.test.ts": 13, + "src/shared/pty-consumer-session.test.ts": 17, + "src/shared/pty-delivery-diagnostics.test.ts": 14, + "src/shared/pty-liveness-verdict.test.ts": 6, + "src/shared/pty-owner-backend.test.ts": 4, + "src/shared/pty-slave-line-discipline-echo.test.ts": 13, + "src/shared/pty-startup-ingress-live-query-reply.test.ts": 23, + "src/shared/pty-startup-ingress.test.ts": 60, + "src/shared/pty-startup-reply-echo-shapes.test.ts": 14, + "src/shared/published-pane-agent-identity.test.ts": 20, + "src/shared/pull-request-generation.test.ts": 11, + "src/shared/quick-open-directory-reader.test.ts": 11, + "src/shared/quick-open-expansion-paths.test.ts": 30, + "src/shared/quick-open-filter.renderer-safety.test.ts": 6, + "src/shared/quick-open-filter.test.ts": 15, + "src/shared/quick-open-git-directory-collapse.test.ts": 51, + "src/shared/quick-open-listing-limits.test.ts": 14, + "src/shared/quick-open-readdir-memory.test.ts": 38, + "src/shared/quick-open-readdir-walk.test.ts": 197, + "src/shared/quick-open-transport-budget.test.ts": 5, + "src/shared/raster-image-base64-preview.test.ts": 322, + "src/shared/raster-image-dimensions.test.ts": 17, + "src/shared/raster-image-preview-limits.test.ts": 7, + "src/shared/rate-limit-reset-format.test.ts": 9, + "src/shared/rate-limit-types.test.ts": 6, + "src/shared/react-update-depth-attribution.test.ts": 7, + "src/shared/reconnect-jitter.test.ts": 5, + "src/shared/relay-frame-buffer.test.ts": 393, + "src/shared/relay-optional-artifacts.test.ts": 5, + "src/shared/relay-version-marker.test.ts": 5, + "src/shared/release-channel.test.ts": 26, + "src/shared/remote-foreground-evidence.test.ts": 9, + "src/shared/remote-pairing-address.test.ts": 18, + "src/shared/remote-pairing-verification.test.ts": 7, + "src/shared/remote-rpc-content-budget.test.ts": 89, + "src/shared/remote-runtime-client-error-classification.test.ts": 8, + "src/shared/remote-runtime-client.test.ts": 4792, + "src/shared/remote-runtime-memory-limits.test.ts": 110, + "src/shared/remote-runtime-outbound-admission.test.ts": 903, + "src/shared/remote-runtime-request-connection-stale.test.ts": 141, + "src/shared/remote-runtime-request-connection.test.ts": 100, + "src/shared/remote-runtime-request-frames.test.ts": 9, + "src/shared/remote-runtime-request-response-router.test.ts": 12, + "src/shared/remote-runtime-request-websocket.test.ts": 32, + "src/shared/remote-runtime-shared-control-boundary.test.ts": 2634, + "src/shared/remote-runtime-shared-control-connection.test.ts": 3301, + "src/shared/remote-runtime-shared-control-keepalive-refresh.test.ts": 14, + "src/shared/remote-runtime-shared-control-reconnect.test.ts": 9, + "src/shared/remote-runtime-shared-control-retired-request-ids.test.ts": 8, + "src/shared/remote-runtime-shared-control-socket-generation.test.ts": 4, + "src/shared/remote-runtime-shared-control-standing-intent.test.ts": 615, + "src/shared/remote-runtime-shared-control-subscription-close.test.ts": 5, + "src/shared/remote-runtime-shared-control-subscriptions.test.ts": 12, + "src/shared/remote-runtime-socket-liveness.test.ts": 9, + "src/shared/remote-runtime-subscription-frame-router.test.ts": 7, + "src/shared/remote-runtime-subscription-request.test.ts": 295, + "src/shared/remote-runtime-tailscale-hint.test.ts": 7, + "src/shared/remote-runtime-transport-error-agreement.test.ts": 9, + "src/shared/remote-workspace-session-projection.test.ts": 9, + "src/shared/renderer-restart-preparation.test.ts": 10, + "src/shared/repo-badge-color.test.ts": 5, + "src/shared/repo-icon.test.ts": 23, + "src/shared/repo-ref-maintenance.test.ts": 316, + "src/shared/repo-search-limits.test.ts": 10, + "src/shared/repro-13889-claude-quarter-circle-busy-title.test.ts": 16, + "src/shared/repro-7732-gitlab-job-id-dropped.test.ts": 5, + "src/shared/repro-8478-opencode-native-title-icon.test.ts": 16, + "src/shared/require-tui-agent-config.test.ts": 4, + "src/shared/resolved-worktree-lineage.test.ts": 10, + "src/shared/retired-pty-incarnations.test.ts": 4, + "src/shared/review-head-tracking-ref.test.ts": 6, + "src/shared/ripgrep-process-availability.test.ts": 11, + "src/shared/runtime-client-export-parity.test.ts": 5, + "src/shared/runtime-environment-store.test.ts": 32, + "src/shared/runtime-host-status-owner.test.ts": 22, + "src/shared/runtime-listing-host-scope.test.ts": 10, + "src/shared/runtime-navigation.test.ts": 5, + "src/shared/runtime-rpc-call-queue.test.ts": 15, + "src/shared/runtime-workspace-file-owner.test.ts": 10, + "src/shared/runtime-workspace-window-availability.test.ts": 5, + "src/shared/search-match-count.test.ts": 7, + "src/shared/search-subprocess-lines.test.ts": 87, + "src/shared/secret-store.test.ts": 8, + "src/shared/secure-file-coarse-ctime.test.ts": 9, + "src/shared/secure-file-fsync-flags.test.ts": 13, + "src/shared/secure-file.test.ts": 602, + "src/shared/secure-path-hardening-cache.test.ts": 9, + "src/shared/secure-path-hardening-retry-budget.test.ts": 39, + "src/shared/serve-option-validation.test.ts": 12, + "src/shared/setup-agent-sequencing.test.ts": 6203, + "src/shared/setup-runner-command.test.ts": 16, + "src/shared/setup-script-imports.test.ts": 26, + "src/shared/setup-script-package-manager-suggestion.test.ts": 14, + "src/shared/setup-script-shebang.test.ts": 7, + "src/shared/setup-script-telemetry-events.test.ts": 10, + "src/shared/setup-script-telemetry.test.ts": 7, + "src/shared/shell-foreground-snapshot.test.ts": 118, + "src/shared/shell-process-readiness.test.ts": 20, + "src/shared/skill-bundle-install-contract.test.ts": 9, + "src/shared/skill-bundle-name.test.ts": 3, + "src/shared/skill-delete-contract.test.ts": 30, + "src/shared/skill-deletion-eligibility.test.ts": 6, + "src/shared/skill-install-contract.test.ts": 13, + "src/shared/skill-install-failure.test.ts": 11, + "src/shared/skill-metadata.test.ts": 6, + "src/shared/skill-package-manifest.test.ts": 78, + "src/shared/skill-path-containment.test.ts": 8, + "src/shared/skills-cli-agent-keys.test.ts": 10, + "src/shared/source-control-ai-action-recipes.test.ts": 11, + "src/shared/source-control-ai-action-variables.test.ts": 12, + "src/shared/source-control-ai-actions.test.ts": 11, + "src/shared/source-control-ai-policy-regression.test.ts": 12, + "src/shared/source-control-ai-recipe-save.test.ts": 15, + "src/shared/source-control-ai.test.ts": 18, + "src/shared/source-control-create-review-intent.test.ts": 12, + "src/shared/source-control-group-order.test.ts": 4, + "src/shared/source-control-primary-action-decision.test.ts": 5, + "src/shared/source-control-push-failure.test.ts": 12, + "src/shared/source-control-recovery-agent-command.test.ts": 7, + "src/shared/source-scan/source-tree-scan.test.ts": 37, + "src/shared/ssh-pending-pty-kill.test.ts": 9, + "src/shared/ssh-pty-id.test.ts": 8, + "src/shared/ssh-relay-pty-ownership-proof.test.ts": 44, + "src/shared/ssh-retained-payload-admission.test.ts": 20, + "src/shared/ssh-target-generation.test.ts": 11, + "src/shared/ssh-types.test.ts": 7, + "src/shared/stable-pane-id.test.ts": 13, + "src/shared/startup-command-submission.test.ts": 6, + "src/shared/status-bar-usage-mode.test.ts": 3, + "src/shared/string-chunk-compaction.test.ts": 19, + "src/shared/structural-value-equality.test.ts": 10, + "src/shared/structured-agent-session-coalescer.test.ts": 7, + "src/shared/structured-agent-session-composer.test.ts": 13, + "src/shared/structured-agent-session-create.test.ts": 16, + "src/shared/structured-agent-session-item-retention.test.ts": 460, + "src/shared/structured-agent-session-live-turn.test.ts": 4, + "src/shared/structured-agent-session-mutation.test.ts": 6, + "src/shared/structured-agent-session-option-picks.test.ts": 8, + "src/shared/structured-agent-session-options.test.ts": 12, + "src/shared/structured-agent-session-projection.test.ts": 14, + "src/shared/structured-agent-session-reducer.test.ts": 43, + "src/shared/structured-agent-session-turn-timing-retention.test.ts": 20, + "src/shared/structured-agent-session-turn-timing.test.ts": 15, + "src/shared/structured-native-chat-launch-route.test.ts": 8, + "src/shared/subprocess-stdin-write.test.ts": 4, + "src/shared/synthetic-agent-title.test.ts": 9, + "src/shared/tab-title-resolution.test.ts": 8, + "src/shared/tailnet-address.test.ts": 5, + "src/shared/task-providers.test.ts": 12, + "src/shared/task-query.test.ts": 24, + "src/shared/task-source-context.test.ts": 12, + "src/shared/telemetry-common-props.test.ts": 9, + "src/shared/telemetry-events-feature-education.test.ts": 12, + "src/shared/telemetry-events.test.ts": 37, + "src/shared/telemetry-feature-wall-events.test.ts": 11, + "src/shared/telemetry-orca-cli-feature-tip.test.ts": 6, + "src/shared/terminal-bell-detector.test.ts": 7, + "src/shared/terminal-color-scheme-protocol.test.ts": 7, + "src/shared/terminal-composer-draft.test.ts": 13, + "src/shared/terminal-custom-themes.test.ts": 10, + "src/shared/terminal-escape-introducer.test.ts": 16, + "src/shared/terminal-exit-cause.test.ts": 8, + "src/shared/terminal-file-url-target.test.ts": 3, + "src/shared/terminal-fonts.test.ts": 7, + "src/shared/terminal-github-pr-link-detector.test.ts": 14, + "src/shared/terminal-input.test.ts": 40, + "src/shared/terminal-kitty-keyboard-mode-tracker.test.ts": 19, + "src/shared/terminal-line-height-settings.test.ts": 4, + "src/shared/terminal-mode-2031-final-state.test.ts": 10, + "src/shared/terminal-mode-reset-profiles.test.ts": 13, + "src/shared/terminal-osc-color-reply.test.ts": 7, + "src/shared/terminal-output-side-effects.test.ts": 14, + "src/shared/terminal-partial-escape-tail-ground-scan.test.ts": 12, + "src/shared/terminal-partial-escape-tail.fuzz.test.ts": 986, + "src/shared/terminal-partial-escape-tail.test.ts": 13, + "src/shared/terminal-query-reply.test.ts": 23, + "src/shared/terminal-quick-commands.test.ts": 10, + "src/shared/terminal-reply-query-scan.test.ts": 5, + "src/shared/terminal-scrollback-policy.test.ts": 11, + "src/shared/terminal-startup-cwd.test.ts": 11, + "src/shared/terminal-stream-protocol.test.ts": 10, + "src/shared/terminal-title-agent-type.test.ts": 27, + "src/shared/terminal-title-classification-corpus.test.ts": 34, + "src/shared/terminal-title-classification-memo.test.ts": 20, + "src/shared/terminal-view-attributes.test.ts": 15, + "src/shared/terminal-wait-blocked-reason-legacy-alias.test.ts": 9, + "src/shared/terminal-zero-dimensions-diagnostic.test.ts": 5, + "src/shared/test-code-path.test.ts": 10, + "src/shared/text-search.test.ts": 53, + "src/shared/timer-delay.test.ts": 9, + "src/shared/tui-agent-config.test.ts": 9, + "src/shared/tui-agent-permissions.test.ts": 10, + "src/shared/tui-agent-selection.test.ts": 10, + "src/shared/tui-agent-startup-copilot-resume.test.ts": 5, + "src/shared/tui-agent-startup-hermes.test.ts": 26, + "src/shared/tui-agent-startup-session-options.test.ts": 13, + "src/shared/tui-agent-startup-shell.test.ts": 23, + "src/shared/tui-agent-startup.test.ts": 29, + "src/shared/ui-language.test.ts": 8, + "src/shared/ui-locale.test.ts": 14, + "src/shared/ui-zoom-level.test.ts": 7, + "src/shared/updated-at-order.test.ts": 82, + "src/shared/updater-windows-signature-check.test.ts": 6, + "src/shared/usage-percentage-display-change-notice.test.ts": 6, + "src/shared/usage-percentage-display.test.ts": 8, + "src/shared/utf8-byte-limits.test.ts": 70, + "src/shared/vscode-remote-ssh-launcher.test.ts": 7, + "src/shared/window-shortcut-policy-agent-dashboard.test.ts": 7, + "src/shared/window-shortcut-policy.test.ts": 25, + "src/shared/windows-cmd-runner-delayed-launch.test.ts": 5, + "src/shared/windows-command-line-budget.test.ts": 1102, + "src/shared/windows-console-input.test.ts": 12, + "src/shared/windows-environment-expansion.test.ts": 48, + "src/shared/windows-interactive-login-spawn.test.ts": 13, + "src/shared/windows-lane-tree-removal-boundary.test.ts": 19, + "src/shared/windows-long-path-git-args.test.ts": 4, + "src/shared/windows-security-descriptor.test.ts": 13, + "src/shared/windows-terminal-shell.test.ts": 9, + "src/shared/windows-transient-lock-removal.test.ts": 1176, + "src/shared/worker-terminal-host-scope.test.ts": 15, + "src/shared/worker-transcript-text-scaling.test.ts": 7, + "src/shared/workspace-cleanup-applied-filters.test.ts": 15, + "src/shared/workspace-cleanup-browse-state-persistence.test.ts": 18, + "src/shared/workspace-cleanup-ui-state.test.ts": 7, + "src/shared/workspace-cleanup.test.ts": 10, + "src/shared/workspace-doc-history.test.ts": 34, + "src/shared/workspace-linked-item-equality.test.ts": 7, + "src/shared/workspace-linked-item-source-context.test.ts": 8, + "src/shared/workspace-name.test.ts": 25, + "src/shared/workspace-session-browser-history.test.ts": 25, + "src/shared/workspace-session-partition-owner.test.ts": 4, + "src/shared/workspace-session-salvage-equivalence.test.ts": 95, + "src/shared/workspace-session-salvage.test.ts": 1522, + "src/shared/workspace-session-schema-field-coverage.test.ts": 15, + "src/shared/workspace-session-schema.sleeping-agent.test.ts": 22, + "src/shared/workspace-session-schema.test.ts": 23, + "src/shared/workspace-session-tab-focus-schema.test.ts": 19, + "src/shared/workspace-session-terminal-buffers.test.ts": 28, + "src/shared/workspace-session-terminal-schema.test.ts": 12, + "src/shared/workspace-session-terminal-tab-close.test.ts": 43, + "src/shared/workspace-session-validation-work.test.ts": 108, + "src/shared/workspace-space-compaction.test.ts": 15, + "src/shared/workspace-space-entry-traversal.test.ts": 1132, + "src/shared/workspace-space-scan-budget.test.ts": 32, + "src/shared/workspace-statuses.test.ts": 24, + "src/shared/worktree-execution-host-resolution.test.ts": 15, + "src/shared/worktree-name-suggestion.test.ts": 29, + "src/shared/worktree/base-ref.test.ts": 11, + "src/shared/worktree/card-properties.test.ts": 6, + "src/shared/worktree/create-preparation.test.ts": 6, + "src/shared/worktree/github-pr-suppression.test.ts": 8, + "src/shared/worktree/host-context-labels.test.ts": 8, + "src/shared/worktree/host-qualified-identity.test.ts": 8, + "src/shared/worktree/id.test.ts": 13, + "src/shared/worktree/identity.test.ts": 7, + "src/shared/worktree/ownership-configured-base-visibility.test.ts": 22, + "src/shared/worktree/ownership-worktree-base-path.test.ts": 8, + "src/shared/worktree/ownership.test.ts": 284, + "src/shared/worktree/removal-fence-error.test.ts": 7, + "src/shared/worktree/removal-force-classification.test.ts": 5, + "src/shared/worktree/retired-name-cache.test.ts": 15, + "src/shared/worktree/retired-name-registry.test.ts": 185, + "src/shared/worktree/sort-order-update.test.ts": 7, + "src/shared/worktree/submodule-removal.test.ts": 7, + "src/shared/worktree/visibility-sources.test.ts": 18, + "src/shared/ws-outbound-backpressure-queue.test.ts": 31, + "src/shared/wsl-exec-mode-separator.test.ts": 2451, + "src/shared/wsl-login-shell-command.test.ts": 66, + "src/shared/wsl-paths.test.ts": 18, + "src/shared/zod-salvage-absence.test.ts": 21, + "tests/e2e/alternate-screen-fixture-script.unit.test.ts": 8, + "tests/e2e/completed-worker-retirement-resume.unit.test.ts": 128, + "tests/e2e/global-teardown.unit.test.ts": 55, + "tests/e2e/helpers/alt-screen-frame.unit.test.ts": 40, + "tests/e2e/helpers/client-hosted-browser-fixture.unit.test.ts": 13, + "tests/e2e/helpers/electron-crashpad-cleanup.unit.test.ts": 11, + "tests/e2e/helpers/electron-home-isolation.unit.test.ts": 7, + "tests/e2e/helpers/electron-launch-args.unit.test.ts": 9, + "tests/e2e/helpers/electron-main-evaluate-retry.unit.test.ts": 1209, + "tests/e2e/helpers/electron-process-shutdown.unit.test.ts": 11, + "tests/e2e/helpers/fake-agent-command-override.unit.test.ts": 3, + "tests/e2e/helpers/fake-agent-paste-end-scanner.unit.test.ts": 16, + "tests/e2e/helpers/git-status-retry-barrier.unit.test.ts": 7, + "tests/e2e/helpers/golden-source-control.unit.test.ts": 11, + "tests/e2e/helpers/headless-paired-runtime-serve-readiness.unit.test.ts": 18, + "tests/e2e/helpers/nested-runtime-proxy-jump-fixture.unit.test.ts": 5, + "tests/e2e/helpers/paired-client-runtime-environment.unit.test.ts": 12, + "tests/e2e/helpers/paired-client-window-reveal.unit.test.ts": 10, + "tests/e2e/helpers/remote-skill-cloud-fixture.unit.test.ts": 138, + "tests/e2e/helpers/remote-terminal-source-range-contract-fixture.unit.test.ts": 7, + "tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts": 57, + "tests/e2e/helpers/streaming-terminal-cleanup.unit.test.ts": 10, + "tests/e2e/host-cold-park-remote-subscriber.unit.test.ts": 518, + "tests/e2e/host-guest-paint-retention-remote-viewer.unit.test.ts": 278, + "tests/e2e/orca-restart-navigation.unit.test.ts": 9, + "tests/e2e/orchestration-run-pagination.unit.test.ts": 9, + "tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts": 935, + "tests/e2e/relay-region-compatibility.unit.test.ts": 56, + "tests/e2e/relay-region-correction.unit.test.ts": 5935, + "tests/e2e/remote-agent-completion-authority.unit.test.ts": 20, + "tests/e2e/remote-terminal-tab-retirement.unit.test.ts": 53, + "tests/e2e/restored-terminal-input-readiness.unit.test.ts": 17, + "tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts": 460, + "tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts": 33, + "tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts": 82, + "tests/e2e/ssh-codex-replay-reply-probe.unit.test.ts": 9, + "tests/e2e/structured-native-chat-routing-authority.unit.test.ts": 67, + "tests/e2e/terminal-foreground-confirmation.unit.test.ts": 19, + "tests/e2e/terminal-probe-input-sequence.unit.test.ts": 9, + "tests/e2e/terminal-split-activation-latency-artifact.unit.test.ts": 11, + "tests/e2e/terminal-split-activation-latency-report.unit.test.ts": 13, + "tests/tools/relay-bench/phone-e2ee-desktop-parity.test.mjs": 48, + "tests/tools/relay-bench/region-probe-replay.test.mjs": 26, + "tests/tools/relay-bench/relay-bench-invocation.test.mjs": 16, + "tests/tools/relay-bench/relay-bench-state-file.test.mjs": 13, + "tests/tools/relay-bench/relay-phone-connect-bench.test.mjs": 27, + "tests/tools/win-update-e2e/app-driver.test.mjs": 23, + "tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-oracle.test.mjs": 8, + "tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.test.mjs": 40, + "tests/tools/windows-pty-native-capability-smoke/run.test.mjs": 11 + } + }, + "e2e": { + "runId": "34652504501", + "jobIds": [ + "103437895060", + "103437895072", + "103437895076", + "103437895077", + "103437895093", + "103437895101", + "103437895106", + "103437895107", + "103437895124", + "103437895125", + "103437895133", + "103437895146", + "103437895183", + "103437895216" + ], + "overheadMs": 0, + "timings": { + "tests/e2e/active-view-restart-restore.spec.ts": 22600, + "tests/e2e/activity-agent-pane-isolation.spec.ts": 102400, + "tests/e2e/add-project-default-checkout.spec.ts": 41600, + "tests/e2e/agent-dashboard-status-burst.spec.ts": 16900, + "tests/e2e/agent-descendant-process-kill.spec.ts": 14800, + "tests/e2e/agent-session-live-force-exit-resume.spec.ts": 31800, + "tests/e2e/agent-session-log-tail-stability.spec.ts": 78000, + "tests/e2e/agent-session-quit-resume.spec.ts": 27800, + "tests/e2e/ai-vault-session-delete.spec.ts": 29800, + "tests/e2e/app-menu-paste-ownership.spec.ts": 31900, + "tests/e2e/artificial-opencode-terminal-load.spec.ts": 230300, + "tests/e2e/automation-hidden-terminal-first-mount.spec.ts": 15100, + "tests/e2e/automation-prompt-disclosure.spec.ts": 27000, + "tests/e2e/automation-runs-dashboard.spec.ts": 18900, + "tests/e2e/browser-address-bar-narrow-toolbar.spec.ts": 17100, + "tests/e2e/browser-embedded-owner-routing.spec.ts": 20900, + "tests/e2e/browser-fedcm-fallback.spec.ts": 15100, + "tests/e2e/browser-guest-attachment-validation.spec.ts": 16900, + "tests/e2e/browser-guest-crash-recovery.spec.ts": 123200, + "tests/e2e/browser-loading-surface.spec.ts": 49800, + "tests/e2e/browser-local-https-certificate-trust.spec.ts": 19500, + "tests/e2e/browser-reload-feedback.spec.ts": 21000, + "tests/e2e/browser-split-shortcuts.spec.ts": 75000, + "tests/e2e/browser-tab.spec.ts": 175700, + "tests/e2e/chinese-ime-chat-input-repro.spec.ts": 49200, + "tests/e2e/combined-diff-invalidation-freeze-repro.spec.ts": 101900, + "tests/e2e/combined-diff-scroll-restore.spec.ts": 51900, + "tests/e2e/completed-worker-retirement-resume.spec.ts": 40400, + "tests/e2e/daemon-generation-legacy-close-safety.spec.ts": 5300, + "tests/e2e/daemon-lifecycle-retirement.spec.ts": 222, + "tests/e2e/daemon-live-session-preservation.spec.ts": 20700, + "tests/e2e/daemon-slow-health-check-preservation.spec.ts": 23700, + "tests/e2e/daemon-slow-init-pty-gate.spec.ts": 20700, + "tests/e2e/default-branch-visibility.spec.ts": 14800, + "tests/e2e/dictation-indicator.spec.ts": 14900, + "tests/e2e/diff-note-delete.spec.ts": 18700, + "tests/e2e/diff-note-edit.spec.ts": 20900, + "tests/e2e/diff-note-layout.spec.ts": 24900, + "tests/e2e/droid-notification.spec.ts": 65000, + "tests/e2e/editable-context-paste-ownership.spec.ts": 15700, + "tests/e2e/editor-tab-selection-restore.spec.ts": 28900, + "tests/e2e/electron-home-isolation.spec.ts": 12800, + "tests/e2e/ephemeral-vm-cleanup-retry.spec.ts": 26800, + "tests/e2e/ephemeral-vm-provisioned-root.spec.ts": 60000, + "tests/e2e/feature-wall.spec.ts": 101800, + "tests/e2e/file-explorer-watch-refresh.spec.ts": 17700, + "tests/e2e/file-open.spec.ts": 60000, + "tests/e2e/finished-agent-ghost-resume.spec.ts": 19100, + "tests/e2e/floating-mobile-emulator-tab.spec.ts": 23000, + "tests/e2e/floating-tab-rename.spec.ts": 56200, + "tests/e2e/folder-setup-shallow-priority.spec.ts": 50100, + "tests/e2e/folder-setup.spec.ts": 56200, + "tests/e2e/git-history-tooltip-wrap.spec.ts": 22900, + "tests/e2e/git-no-upstream-polling-churn.spec.ts": 22600, + "tests/e2e/github-cli-stall-repro.spec.ts": 18200, + "tests/e2e/github-created-issue-start-prefill.spec.ts": 43100, + "tests/e2e/github-url-smart-input-transition.spec.ts": 58200, + "tests/e2e/golden-agent-tui-launch.spec.ts": 27300, + "tests/e2e/golden-core-flows.spec.ts": 91100, + "tests/e2e/golden-file-open-edit-save.spec.ts": 25000, + "tests/e2e/golden-fresh-profile-terminal.spec.ts": 24400, + "tests/e2e/golden-posix-fresh-startup.spec.ts": 4900, + "tests/e2e/golden-posix-profile-index-fsync.spec.ts": 4900, + "tests/e2e/golden-quit-relaunch-session.spec.ts": 29700, + "tests/e2e/golden-shell-after-agent-exit.spec.ts": 28300, + "tests/e2e/golden-shell-command.spec.ts": 15400, + "tests/e2e/golden-source-control-commit.spec.ts": 19100, + "tests/e2e/golden-source-control-open-diff.spec.ts": 20900, + "tests/e2e/golden-tab-bar-agent-launch.spec.ts": 54100, + "tests/e2e/golden-terminal-file-link.spec.ts": 43300, + "tests/e2e/golden-worktree-create-switch.spec.ts": 29100, + "tests/e2e/grok-hook-session-cleanup.spec.ts": 7700, + "tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts": 58000, + "tests/e2e/headless-serve-cli-terminal-retention-parity.spec.ts": 25300, + "tests/e2e/headless-serve-desktop-activation.spec.ts": 16700, + "tests/e2e/headless-serve-focused-terminal-create.spec.ts": 13200, + "tests/e2e/host-parked-pane-remote-viewer.spec.ts": 144000, + "tests/e2e/issue-12656-terminal-link-tooltip.spec.ts": 21000, + "tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts": 104400, + "tests/e2e/landing-preflight-runtime-routing.spec.ts": 11000, + "tests/e2e/large-diff-freeze-repro.spec.ts": 35800, + "tests/e2e/linear-filter-chip-labels.spec.ts": 29800, + "tests/e2e/linear-issue-view-persistence.spec.ts": 198000, + "tests/e2e/linear-url-workspace-entry.spec.ts": 73500, + "tests/e2e/live-background-terminal-mount-authority.spec.ts": 33300, + "tests/e2e/local-cli-terminal-retention.spec.ts": 18300, + "tests/e2e/local-worktree-visibility-runtime-active.spec.ts": 14700, + "tests/e2e/macos-press-and-hold-startup.spec.ts": 12700, + "tests/e2e/manual-worktree-order-persistence.spec.ts": 36100, + "tests/e2e/markdown-add-review-note-shortcut.spec.ts": 68300, + "tests/e2e/markdown-explorer-find-focus.spec.ts": 16300, + "tests/e2e/markdown-nested-toggle.spec.ts": 50700, + "tests/e2e/markdown-ordered-list-exit.spec.ts": 53200, + "tests/e2e/markdown-prose-reflow.spec.ts": 62000, + "tests/e2e/markdown-table-row-backspace.spec.ts": 24600, + "tests/e2e/mobile-banner.spec.ts": 89300, + "tests/e2e/multi-client-navigation-isolation.spec.ts": 99900, + "tests/e2e/native-chat-ask-user-question-card.spec.ts": 23600, + "tests/e2e/native-chat-first-flush-race.spec.ts": 22700, + "tests/e2e/native-chat-history-prepend-anchor.spec.ts": 29400, + "tests/e2e/new-workspace-create-more.spec.ts": 36300, + "tests/e2e/new-workspace-cross-project-dialog.spec.ts": 17100, + "tests/e2e/new-workspace-linked-item-project-switch.spec.ts": 45200, + "tests/e2e/notification-settings.spec.ts": 23500, + "tests/e2e/onboarding.spec.ts": 276800, + "tests/e2e/orchestration-idle-mail-delivery.spec.ts": 220800, + "tests/e2e/orchestration-idle-mail-restore.spec.ts": 27000, + "tests/e2e/orchestration-legacy-worker-missing-terminal-recovery.spec.ts": 22200, + "tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts": 50700, + "tests/e2e/orchestration-low-level-dispatch-release.spec.ts": 15000, + "tests/e2e/orchestration-worker-settlement-release-cli.spec.ts": 18100, + "tests/e2e/orchestration-worker-terminal-visibility.spec.ts": 53200, + "tests/e2e/orchestration-worker-transcript-providers.spec.ts": 54700, + "tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts": 21600, + "tests/e2e/paired-cli-terminal-graph-sync-tab-retention.spec.ts": 42600, + "tests/e2e/paired-client-hosted-browser-cookie-survival.spec.ts": 20100, + "tests/e2e/paired-client-hosted-browser-double-restart.spec.ts": 45800, + "tests/e2e/paired-client-hosted-browser-ghost-close.spec.ts": 48600, + "tests/e2e/paired-client-hosted-browser-host-strip.spec.ts": 23300, + "tests/e2e/paired-client-hosted-browser-quit-survival.spec.ts": 145700, + "tests/e2e/paired-client-hosted-browser-restart-survival.spec.ts": 20700, + "tests/e2e/paired-client-hosted-browser-title-hold.spec.ts": 24300, + "tests/e2e/paired-client-hosted-browser.spec.ts": 53000, + "tests/e2e/paired-cmd-j-host-qualified-tabs.spec.ts": 53300, + "tests/e2e/paired-external-worktree-discovery.spec.ts": 19300, + "tests/e2e/paired-instant-browser-tab.spec.ts": 191700, + "tests/e2e/paired-preview-address-bar-convergence.spec.ts": 162000, + "tests/e2e/paired-quick-open-large-tree.spec.ts": 26300, + "tests/e2e/paired-remote-browser-ghost-subscriber-rejoin.spec.ts": 22600, + "tests/e2e/paired-remote-browser-link-open-routing.spec.ts": 78000, + "tests/e2e/paired-remote-browser-stream-reconnect.spec.ts": 57200, + "tests/e2e/paired-remote-html-preview-local-render.spec.ts": 262700, + "tests/e2e/paired-remote-pane-layout-retry.spec.ts": 28200, + "tests/e2e/paired-remote-split-pane-host-retired-ghost.spec.ts": 20000, + "tests/e2e/paired-remote-terminal-browser-link.spec.ts": 78000, + "tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts": 35600, + "tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts": 27100, + "tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts": 39700, + "tests/e2e/paired-remote-terminal-serve-restart-binding.spec.ts": 28300, + "tests/e2e/paired-skill-installation.spec.ts": 26500, + "tests/e2e/paired-split-pane-browser-placement.spec.ts": 85900, + "tests/e2e/paired-startup-exec-readiness.spec.ts": 6000, + "tests/e2e/paired-web-add-project-unavailable-host.spec.ts": 13200, + "tests/e2e/persisted-session-production-upgrade.spec.ts": 26700, + "tests/e2e/pet-status-segment-layout.spec.ts": 25100, + "tests/e2e/pi-ui-prompt-status.spec.ts": 21100, + "tests/e2e/plugin-demo.spec.ts": 26600, + "tests/e2e/plugin-marketplace-content.spec.ts": 39600, + "tests/e2e/plugin-panel-containment.spec.ts": 50300, + "tests/e2e/plugin-startup-budget.spec.ts": 20400, + "tests/e2e/pr-comments-sidebar-cards.spec.ts": 144300, + "tests/e2e/pr11346-selected-runtime-add.spec.ts": 33800, + "tests/e2e/project-group-creation-visibility.spec.ts": 132000, + "tests/e2e/project-group-manual-sort.spec.ts": 100300, + "tests/e2e/pty-snapshot-capability-main-stall.spec.ts": 16100.000000000002, + "tests/e2e/quick-open-file-paths.spec.ts": 22800, + "tests/e2e/remote-session-bulk-open-freeze-repro.spec.ts": 51500, + "tests/e2e/renderer-blocked-navigation-graph-authority.spec.ts": 43600, + "tests/e2e/renderer-crash-recovery-terminal-input.spec.ts": 34500, + "tests/e2e/repo-icon-emoji-picker.spec.ts": 22900, + "tests/e2e/repro-7732-gitlab-checks-job-details.spec.ts": 29900, + "tests/e2e/resource-manager-unbound-session-safety.spec.ts": 18700, + "tests/e2e/resource-usage-warm-reattach.spec.ts": 18700, + "tests/e2e/restart-restore-terminal-input.spec.ts": 77800, + "tests/e2e/rich-markdown-inline-image.spec.ts": 55200, + "tests/e2e/rich-markdown-link-bubble-stacking.spec.ts": 30100, + "tests/e2e/right-sidebar-windows-titlebar.spec.ts": 14600, + "tests/e2e/runtime-host-status-recovery.spec.ts": 156000, + "tests/e2e/settings-agent-awake.spec.ts": 33400, + "tests/e2e/settings-display-name-ime.spec.ts": 18100, + "tests/e2e/settings-search-responsiveness.spec.ts": 15600, + "tests/e2e/settings-search-shortcuts-pane.spec.ts": 15800, + "tests/e2e/settings-skill-detection.spec.ts": 21800, + "tests/e2e/settled-worker-tab-survives-restart.spec.ts": 62400, + "tests/e2e/setup-guide-sidebar.spec.ts": 6800, + "tests/e2e/setup-script-import.spec.ts": 57600, + "tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts": 19100, + "tests/e2e/sidebar-agent-row-identity.spec.ts": 16700, + "tests/e2e/slept-workspace-remount-wake.spec.ts": 31000, + "tests/e2e/source-control-commit-draft-persistence.spec.ts": 16400, + "tests/e2e/source-control-commit-message-ai.spec.ts": 50900, + "tests/e2e/source-control-create-pr-intent-notice-layout.spec.ts": 16900, + "tests/e2e/source-control-create-pr-intent-switch.spec.ts": 41000, + "tests/e2e/source-control-create-pr.spec.ts": 37500, + "tests/e2e/source-control-discard-confirmation.spec.ts": 25900, + "tests/e2e/source-control-large-file-count.spec.ts": 54700, + "tests/e2e/source-control-pr-generation-switch.spec.ts": 146600, + "tests/e2e/source-control-pr-linked-issue-ai.spec.ts": 34200, + "tests/e2e/ssh-config-host-import.spec.ts": 204600, + "tests/e2e/ssh-config-host-picker.spec.ts": 206600, + "tests/e2e/ssh-host-form-modal.spec.ts": 94700, + "tests/e2e/status-bar-caffeinate.spec.ts": 17900, + "tests/e2e/status-bar-session-count-management-kill.spec.ts": 26000, + "tests/e2e/tab-close-navigation.spec.ts": 32100, + "tests/e2e/tab-create-entry-file-paths.spec.ts": 23600, + "tests/e2e/tab-rename-paste-ownership.spec.ts": 15600, + "tests/e2e/tab-rename.spec.ts": 149900, + "tests/e2e/tab-sidebar-closed-overlap.spec.ts": 28600, + "tests/e2e/tabs.spec.ts": 164500, + "tests/e2e/tasks-page.spec.ts": 90400, + "tests/e2e/terminal-attention.spec.ts": 69700, + "tests/e2e/terminal-cjk-ime-committed-text.spec.ts": 122400, + "tests/e2e/terminal-codex-hidden-startup-background.spec.ts": 17500, + "tests/e2e/terminal-codex-home.spec.ts": 14600, + "tests/e2e/terminal-cold-activation-deferral.spec.ts": 22200, + "tests/e2e/terminal-column-desync-repro.spec.ts": 149400, + "tests/e2e/terminal-context-menu-session-id.spec.ts": 20800, + "tests/e2e/terminal-cursor-inactive-style.spec.ts": 15700, + "tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts": 23000, + "tests/e2e/terminal-foreground-redraw-freeze.spec.ts": 14800, + "tests/e2e/terminal-hangul-wrap-boundary-bytes.spec.ts": 107100, + "tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts": 76200, + "tests/e2e/terminal-hidden-tui-visual-restore.spec.ts": 70600, + "tests/e2e/terminal-hidden-view-parking.spec.ts": 199400, + "tests/e2e/terminal-history-size-typing-latency.spec.ts": 21200, + "tests/e2e/terminal-ime-exact-byte.spec.ts": 60400, + "tests/e2e/terminal-inline-tui-reveal-convergence.spec.ts": 309900, + "tests/e2e/terminal-korean-composing-chord-order.spec.ts": 16400, + "tests/e2e/terminal-korean-endofrow-preedit-cell-span.spec.ts": 29800, + "tests/e2e/terminal-korean-midline-preedit-occlusion.spec.ts": 45100, + "tests/e2e/terminal-korean-preedit-visibility.spec.ts": 62200, + "tests/e2e/terminal-large-paste-responsiveness.spec.ts": 15400, + "tests/e2e/terminal-link-click-ownership.spec.ts": 69100, + "tests/e2e/terminal-link-hover-after-worktree-return.spec.ts": 46600, + "tests/e2e/terminal-long-table-scroll-restore.spec.ts": 74600, + "tests/e2e/terminal-macos-system-key-remap.spec.ts": 97000, + "tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts": 21600, + "tests/e2e/terminal-osc-color-queries.spec.ts": 14900, + "tests/e2e/terminal-osc8-cold-park-restore.spec.ts": 19000, + "tests/e2e/terminal-output-scheduler.spec.ts": 54700, + "tests/e2e/terminal-pane-binding-lifecycle.spec.ts": 46800, + "tests/e2e/terminal-pane-close-layout-consistency.spec.ts": 128900, + "tests/e2e/terminal-pane-content-retention.spec.ts": 45100, + "tests/e2e/terminal-pane-layout-resize.spec.ts": 47500, + "tests/e2e/terminal-pane-split-identity.spec.ts": 81000, + "tests/e2e/terminal-pane-title-editing.spec.ts": 189100, + "tests/e2e/terminal-pane-title-focus-handoff.spec.ts": 158000, + "tests/e2e/terminal-pane-title-strip-placement.spec.ts": 62400, + "tests/e2e/terminal-parked-cli-split.spec.ts": 28900, + "tests/e2e/terminal-parked-close-retirement.spec.ts": 16300, + "tests/e2e/terminal-parked-memory.spec.ts": 80100, + "tests/e2e/terminal-paste-ownership.spec.ts": 66100, + "tests/e2e/terminal-pinned-viewport-streaming-switch.spec.ts": 19400, + "tests/e2e/terminal-pinned-viewport-worktree-switch.spec.ts": 16700, + "tests/e2e/terminal-push-delivery-loss-recovery.spec.ts": 16500, + "tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts": 42100, + "tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts": 35800, + "tests/e2e/terminal-reattach-mouse-mode-leak.spec.ts": 22800, + "tests/e2e/terminal-reattach-tui-mouse-mode.spec.ts": 44200, + "tests/e2e/terminal-restart-persistence.spec.ts": 109600, + "tests/e2e/terminal-reveal-paused-render-repro.spec.ts": 31100, + "tests/e2e/terminal-reveal-stale-pty-resize-repro.spec.ts": 49000, + "tests/e2e/terminal-scroll-intent-follow.spec.ts": 73200, + "tests/e2e/terminal-send-agent-prompt-submit.spec.ts": 51400, + "tests/e2e/terminal-shortcuts.spec.ts": 91700, + "tests/e2e/terminal-sleep-wake-restore.spec.ts": 16800, + "tests/e2e/terminal-split-pane-paste-ownership.spec.ts": 30700, + "tests/e2e/terminal-streaming-refocus-viewport.spec.ts": 15800, + "tests/e2e/terminal-stuck-occlusion-recovery.spec.ts": 15200, + "tests/e2e/terminal-tab-close-restart-persistence.spec.ts": 22800, + "tests/e2e/terminal-tab-close-running-confirm-mouse.spec.ts": 57100, + "tests/e2e/terminal-tab-close-running-confirm.spec.ts": 27500, + "tests/e2e/terminal-tab-switch-sigwinch-restore.spec.ts": 30400, + "tests/e2e/terminal-tab-switch-visual-restore.spec.ts": 90300, + "tests/e2e/terminal-tui-wheel-drain.spec.ts": 528000, + "tests/e2e/terminal-tui-wheel-reports.spec.ts": 34400, + "tests/e2e/terminal-typing-latency.spec.ts": 15200, + "tests/e2e/terminal-wedged-write-pipeline-recovery.spec.ts": 71900, + "tests/e2e/terminal-window-wake-stale-grid-repro.spec.ts": 15900, + "tests/e2e/update-install-renderer-checkpoint-recovery.spec.ts": 14900, + "tests/e2e/usage-overview.spec.ts": 33800, + "tests/e2e/voice-microphone-selection.spec.ts": 34000, + "tests/e2e/windows-terminal-env-icons.spec.ts": 15400, + "tests/e2e/workspace-back-forward-navigation.spec.ts": 108500, + "tests/e2e/workspace-board-lane-virtualization.spec.ts": 107700, + "tests/e2e/workspace-emoji-picker.spec.ts": 32900, + "tests/e2e/workspace-session-corrupt-tab-salvage.spec.ts": 21100, + "tests/e2e/workspace-space-git-status.spec.ts": 16100.000000000002, + "tests/e2e/worktree-active-delete-scroll-position.spec.ts": 59200, + "tests/e2e/worktree-card-downward-drag.spec.ts": 60000, + "tests/e2e/worktree-delete-shortcut.spec.ts": 20600, + "tests/e2e/worktree-jump-palette-filter.spec.ts": 96200, + "tests/e2e/worktree-lifecycle.spec.ts": 44000, + "tests/e2e/worktree-lineage-agent-expansion.spec.ts": 21800, + "tests/e2e/worktree-lineage.spec.ts": 96700, + "tests/e2e/worktree-recent-sort.spec.ts": 28400, + "tests/e2e/worktree-scroll-to-current.spec.ts": 62400, + "tests/e2e/worktree-smart-sort.spec.ts": 14100, + "tests/e2e/worktree-switch-first-paint.spec.ts": 52200, + "tests/e2e/worktree-switch-responsiveness.spec.ts": 15100, + "tests/e2e/worktree.spec.ts": 118100 + } + } +} diff --git a/config/scripts/ci-shard-timings.md b/config/scripts/ci-shard-timings.md new file mode 100644 index 00000000000..f6e846cfd6d --- /dev/null +++ b/config/scripts/ci-shard-timings.md @@ -0,0 +1,93 @@ +# Timing-based CI shards + +The eight unit shards and fourteen general E2E shards use longest-processing-time +assignment of whole files to the currently lightest shard. Ties use file path and +then shard index, independent of filesystem enumeration and locale. Unknown, +zero, or invalid durations use the baseline's positive median (1 second when no +positive evidence exists). Deleted files never enter discovery. Unit weights add +526ms per file for measured transform/setup/import/environment overhead. + +Unit assignment runs inside Vitest's sequencer after discovery and CLI exclusions; +Vitest's default sort, workers and isolation remain intact. It is enabled only by +`ORCA_BALANCE_UNIT_SHARDS=1`; ordinary local runs and explicit file filters retain +their existing behavior. E2E uses Playwright's native `--list` and `--test-list`, +retaining project filters, skipped tests and complete serial groups within files. +The workflow verifies selected test IDs against full discovery before executing. +Dedicated SSH, native IME, WSL and first-paint lanes are unchanged. + +## Evidence and limits + +`ci-shard-timings.json` records run IDs and every contributing job ID: + +- Unit run **34675583768**, Node 24, all eight successful shards: 8,484 completed + file durations. The summed transform/setup/import/environment durations divided + by measured file count give a rounded-up **526ms** per-file overhead allowance. + The original shard weighted loads were **764–849 worker-seconds**, versus + **792–792** after balancing the identical measured files. File counts change + from **1,056–1,065** to **1,060–1,061**. +- General E2E run **34652504501**, all fourteen shard logs: 291 files with completed + headless test durations, including failures. Headful benchmark reruns are not + counted. Original completed test loads were **540–1,727 seconds**, versus + **1,083–1,093** after whole-file balancing on the same measured files. The longest + measured file is **528 seconds**, below the balanced shard load. +- Current checkout discovery at validation contained **8,553 unit files** after the + workflow's exact exclusions and **733 headless E2E tests in 340 files**. New and + unmeasured files remain selected. Projected current loads were about **797 + worker-seconds** per unit shard (1,068–1,070 files) and **1,190–1,200 seconds** per + E2E shard (22–25 files). + +These are scheduling projections, not measured post-change wall-clock gains. +Unit durations overlap across workers and the overhead allowance is an average, +not a per-file import profile. E2E evidence includes failed shards and can omit +unfinished tests; unknowns receive a deterministic estimate. Historical timings +age as specs change. Full CI runs on the existing runner classes are required to +measure elapsed-time and occupancy improvements, including discovery overhead. +No retries, assertions, coverage exclusions, runner classes or shard counts changed. + +## Reproduction and refresh + +Every shard uploads an artifact named with its shard, Node version where relevant, +and run attempt. `assignment.json` contains the checked-out source SHA, run ID, +attempt, baseline SHA-256, algorithm, fallback, all shard files and chosen shard. +E2E also retains both discovery reports and `selected.txt`. Artifacts live for +14 days. A rerun of the same source uses the same checked-in baseline rather than +mutable timing caches; a GitHub job rerun therefore keeps its assignment. + +For E2E reproduction, check out the recorded source and pass the saved list to the +existing command: `pnpm run test:e2e --test-list=/path/to/selected.txt` with the same +CI environment/build inputs. For unit reproduction, use the unchanged workflow +command and exclusions with `ORCA_BALANCE_UNIT_SHARDS=1` and the recorded +`--shard=INDEX/8`. Direct test-file reruns remain supported. + +To refresh the baseline, download `log-JOB_ID.txt` files into one directory from +exactly one eight-shard unit run and one fourteen-shard general E2E run. Use the +job IDs from the Actions jobs API and fetch each with +`gh api repos/stablyai/orca/actions/jobs/JOB_ID/logs`. Do not include dedicated +lanes or multiple attempts. Then run: + +```sh +node config/scripts/ci-shard-timing-import.mjs LOG_DIRECTORY UNIT_RUN_ID E2E_RUN_ID config/scripts/ci-shard-timings.json +``` + +The initial source logs are in `/tmp/orca-ci-shard-logs`; two were reused from +`/tmp/orca-ci-audit`, and the remaining twenty were fetched read-only. Reimporting +those logs reproduced the checked-in JSON byte-for-byte. Review file-count and +load projections before adopting a new baseline; no network access is needed to +plan or run shards. + +## Validation + +- 74 focused tests passed across the two new test files and existing PR + parallelism, E2E gate and release E2E dispatch contracts. +- The pinned Playwright CLI selected the real 733-test suite across all fourteen + saved test lists with exact-once identity coverage and no missing tests. +- A temporary native Playwright fixture checks fourteen shards, serial groups, + skipped cases, headful filtering and mismatch rejection without launching UI. +- Real Vitest discovery with all workflow exclusions yielded 8,553 files; the + sequencer's eight assignments covered each exactly once. An actual opt-in + Vitest shard executed successfully and persisted its manifest. +- Focused TypeScript checking of `config/vitest.config.ts` and imported modules, + oxlint, formatting and baseline reimport checks passed. + +All local tests used `ORCA_BACKGROUND_LAUNCH=1` in background tool sessions. No app +windows or full E2E test bodies were launched. diff --git a/config/scripts/ci-unit-sequencer.mjs b/config/scripts/ci-unit-sequencer.mjs new file mode 100644 index 00000000000..cd55b3343b2 --- /dev/null +++ b/config/scripts/ci-unit-sequencer.mjs @@ -0,0 +1,19 @@ +import { relative } from 'node:path' +import { BaseSequencer } from 'vitest/node' +import { balanceFiles, readTimingBaseline, writeAssignment } from './ci-shard-assignment.mjs' + +export default class TimingSequencer extends BaseSequencer { + async shard(specs) { + const { index, count } = this.ctx.config.shard + const key = (spec) => relative(this.ctx.config.root, spec.moduleId).replaceAll('\\', '/') + const baseline = readTimingBaseline('unit') + const assignment = balanceFiles(specs.map(key), count, baseline.timings, baseline.overheadMs) + writeAssignment(process.env.ORCA_SHARD_MANIFEST ?? 'ci-shards/unit-assignment.json', { + ...assignment, + baselineSha256: baseline.baselineSha256, + selectedShard: index + }) + const selected = new Set(assignment.shards[index - 1].files) + return specs.filter((spec) => selected.has(key(spec))) + } +} diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts index a7ac04a1a09..285b58f2ac7 100644 --- a/config/scripts/electron-vite-output-contract.test.ts +++ b/config/scripts/electron-vite-output-contract.test.ts @@ -116,9 +116,9 @@ describe('Electron Vite output contract', () => { expect(external('node:fs', undefined, false)).toBe(true) expect(external('@xterm/headless', undefined, false)).toBe(false) expect(external('@xterm/addon-serialize', undefined, false)).toBe(false) - expect(external('psl', undefined, false)).toBe(false) + expect(external('tldts', undefined, false)).toBe(false) expect(external('zod', undefined, false)).toBe(false) - expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('psl') + expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('tldts') expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('zod') }) diff --git a/config/scripts/generate-rpc-params-catalog.mjs b/config/scripts/generate-rpc-params-catalog.mjs index acf31aae6a1..e42ea39beb1 100644 --- a/config/scripts/generate-rpc-params-catalog.mjs +++ b/config/scripts/generate-rpc-params-catalog.mjs @@ -197,9 +197,10 @@ ${uncataloged.map((name) => ` '${name}'`).join(',\n')} export type RpcMethodName = keyof typeof RPC_PARAMS_BY_METHOD -// Why: z.output is the post-parse shape the handler receives. z.input is not a -// send-side type here — requiredString is z.unknown().transform(...), so its input -// admits any value and loses optional/default semantics. +// Why: z.output is the post-parse shape the handler receives, which is not what a +// client may send — a .default() field reads as required. z.input is not the answer +// either: requiredString is z.unknown().transform(...), so its input admits any value. +// Senders use RpcSendParams from ./rpc-send-params, which is derived from this map. export type RpcParams = (typeof RPC_PARAMS_BY_METHOD)[Method] extends z.ZodType ? z.output<(typeof RPC_PARAMS_BY_METHOD)[Method]> diff --git a/config/scripts/headless-serve-shutdown-matrix.test.mjs b/config/scripts/headless-serve-shutdown-matrix.test.mjs new file mode 100644 index 00000000000..7231cc21e4f --- /dev/null +++ b/config/scripts/headless-serve-shutdown-matrix.test.mjs @@ -0,0 +1,140 @@ +import { createHash } from 'node:crypto' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { spawnSync } = vi.hoisted(() => ({ spawnSync: vi.fn() })) +vi.mock('node:child_process', () => ({ spawnSync })) + +let directory +let artifact +let originalArgv +let originalExitCode +const commands = () => spawnSync.mock.calls.map(([, args]) => args) +const signalRuns = () => commands().filter((args) => ['INT', 'TERM'].includes(args.at(-1))) +const succeeded = { status: 0, stdout: '', stderr: '' } + +async function run(...options) { + process.argv = ['node', 'runner', '--appimage', artifact, ...options] + await import('./run-headless-serve-shutdown-docker.mjs') +} + +beforeEach(() => { + vi.resetModules() + spawnSync.mockReset().mockReturnValue(succeeded) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + directory = mkdtempSync(join(tmpdir(), 'orca-shutdown-matrix-')) + artifact = join(directory, 'original.AppImage') + writeFileSync(artifact, 'original package bytes') + originalArgv = process.argv + originalExitCode = process.exitCode +}) + +afterEach(() => { + process.argv = originalArgv + process.exitCode = originalExitCode + rmSync(directory, { recursive: true, force: true }) + vi.restoreAllMocks() +}) + +describe('packaged shutdown matrix', () => { + it('shares extraction but isolates every entrypoint and signal', async () => { + await run('--all-entrypoints') + expect(commands().filter((args) => args[0] === 'build')).toHaveLength(1) + const startup = commands().filter((args) => + args.includes('/usr/local/bin/run-appimage-desktop-startup-case') + ) + const extraction = commands().filter((args) => + args.some((arg) => arg.includes('120s /input/orca.AppImage --appimage-extract')) + ) + expect(startup).toHaveLength(1) + expect(extraction).toHaveLength(1) + expect(commands().indexOf(startup[0])).toBeLessThan(commands().indexOf(extraction[0])) + expect(signalRuns()).toHaveLength(6) + const names = new Set() + for (const [index, args] of signalRuns().entries()) { + const entrypoint = ['app', 'launcher', 'appimage'][Math.floor(index / 2)] + expect(args).toContain(`ORCA_TEST_ENTRYPOINT=${entrypoint}`) + expect(args).toContain( + `ORCA_SIGNAL_TARGET=${entrypoint === 'appimage' ? 'serving-electron' : 'app'}` + ) + expect(args).toContain( + `ORCA_INT_DELIVERY=${entrypoint === 'appimage' ? 'pid' : 'foreground-process-group'}` + ) + expect(args.at(-1)).toBe(index % 2 === 0 ? 'INT' : 'TERM') + expect(args).toContain(`${artifact}:/input/orca.AppImage:ro`) + expect(args.some((arg) => arg.endsWith(':/artifacts:ro'))).toBe(true) + expect(args).toContain('--rm') + names.add(args[args.indexOf('--name') + 1]) + } + expect(names.size).toBe(6) + const evidence = console.log.mock.calls + .map(([line]) => line) + .filter((line) => line.startsWith('{')) + .map(JSON.parse) + expect(evidence).toHaveLength(3) + expect( + evidence.every( + (entry) => + entry.sha256 === createHash('sha256').update('original package bytes').digest('hex') + ) + ).toBe(true) + expect( + commands() + .slice(-2) + .map((args) => args.slice(0, 2)) + ).toEqual([ + ['volume', 'rm'], + ['image', 'rm'] + ]) + }) + + it('attributes failures and still attempts later cases before cleanup', async () => { + spawnSync.mockImplementation((_, args) => + args.at(-1) === 'INT' ? { ...succeeded, status: 7 } : succeeded + ) + await expect(run('--all-entrypoints')).rejects.toThrow( + 'app:INT:7, launcher:INT:7, appimage:INT:7' + ) + expect(signalRuns()).toHaveLength(6) + expect(commands().at(-2).slice(0, 2)).toEqual(['volume', 'rm']) + }) + + it('cleans setup resources without running cases after failed extraction', async () => { + spawnSync.mockImplementation((_, args) => + args.some((arg) => arg.includes('120s /input/orca.AppImage --appimage-extract')) + ? { ...succeeded, status: 9 } + : succeeded + ) + await expect(run('--all-entrypoints')).rejects.toThrow('docker run failed') + expect(signalRuns()).toHaveLength(0) + expect( + commands() + .slice(-2) + .map((args) => args.slice(0, 2)) + ).toEqual([ + ['volume', 'rm'], + ['image', 'rm'] + ]) + }) + + it('preserves individual launcher overlay invocations', async () => { + await run('--entrypoint', 'launcher', '--launcher-exec-overlay') + expect(signalRuns()).toHaveLength(2) + expect(signalRuns().every((args) => args.includes('ORCA_TEST_ENTRYPOINT=launcher'))).toBe(true) + expect( + commands().some((args) => + args.some((arg) => arg.includes("sed -i 's/^ELECTRON_RUN_AS_NODE=1")) + ) + ).toBe(true) + }) + + it('rejects ambiguous matrix overrides before invoking Docker', async () => { + await expect(run('--all-entrypoints', '--entrypoint', 'launcher')).rejects.toThrow( + 'cannot be combined' + ) + expect(spawnSync).not.toHaveBeenCalled() + }) +}) diff --git a/config/scripts/headless-serve-shutdown-workflow.test.mjs b/config/scripts/headless-serve-shutdown-workflow.test.mjs index 90a3f73c77d..3b71a2e774e 100644 --- a/config/scripts/headless-serve-shutdown-workflow.test.mjs +++ b/config/scripts/headless-serve-shutdown-workflow.test.mjs @@ -56,12 +56,6 @@ describe('headless serve shutdown PR gate', () => { const packageStep = steps.find((step) => step.name === 'Package unpacked app') const markerStep = steps.find((step) => step.name === 'Verify root-package marker payloads') const shutdownStep = steps.find((step) => step.name === 'Verify headless serve signal shutdown') - const launcherShutdownStep = steps.find( - (step) => step.name === 'Verify extracted launcher serve signal shutdown' - ) - const appImageShutdownStep = steps.find( - (step) => step.name === 'Verify AppImage CLI registration and serve signal shutdown' - ) expect(workflow.jobs.package['timeout-minutes']).toBe(90) expect(packageStep.run).toContain('--linux AppImage deb rpm --x64 --publish never') @@ -69,19 +63,13 @@ describe('headless serve shutdown PR gate', () => { expect(markerStep.run).toContain('rpm2cpio') expect(steps.indexOf(markerStep)).toBeGreaterThan(steps.indexOf(packageStep)) expect(shutdownStep.run).toBe( - 'node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage' + 'node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage --all-entrypoints' ) - expect(launcherShutdownStep.run).toContain( - 'node config/scripts/run-headless-serve-shutdown-docker.mjs' - ) - expect(launcherShutdownStep.run).toContain('--entrypoint launcher') - expect(appImageShutdownStep.run).toContain('--entrypoint appimage') - expect(appImageShutdownStep.run).toContain('--signal-target serving-electron') - expect(appImageShutdownStep.run).toContain('--int-delivery pid') expect(steps.indexOf(shutdownStep)).toBeGreaterThan(steps.indexOf(packageStep)) expect(steps.indexOf(shutdownStep)).toBeGreaterThan(steps.indexOf(markerStep)) - expect(steps.indexOf(launcherShutdownStep)).toBeGreaterThan(steps.indexOf(shutdownStep)) - expect(steps.indexOf(appImageShutdownStep)).toBeGreaterThan(steps.indexOf(launcherShutdownStep)) + expect( + steps.filter((step) => step.run?.includes('run-headless-serve-shutdown-docker.mjs')) + ).toHaveLength(1) }) it('keeps readiness polling finite and leak-free', () => { diff --git a/config/scripts/hermes-run-correlation-benchmark.mjs b/config/scripts/hermes-run-correlation-benchmark.mjs new file mode 100644 index 00000000000..364774577cf --- /dev/null +++ b/config/scripts/hermes-run-correlation-benchmark.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict' +import { readFile, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, basename } from 'node:path' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import { build } from 'esbuild' + +const root = fileURLToPath(new URL('../..', import.meta.url)) +const fixture = await mkdtemp(join(tmpdir(), 'orca-hermes-correlation-')) +const baselineDirectory = process.argv[2] +const key = (seconds) => + new Date(Date.UTC(2026, 0, 1) + seconds * 1000) + .toISOString() + .replace(/[-:]/g, '') + .replace('T', '_') + .slice(0, 15) +try { + for (const host of ['native', 'relay']) { + const entry = + host === 'native' + ? 'src/main/automations/hermes-cron-run-content.ts' + : 'src/relay/hermes-run-correlation.ts' + const readers = [] + for (const mode of baselineDirectory ? ['baseline', 'current'] : ['current']) { + const bundle = join(fixture, `${host}-${mode}.cjs`) + await build({ + entryPoints: [join(root, entry)], + bundle: true, + platform: 'node', + format: 'cjs', + outfile: bundle, + plugins: + mode === 'baseline' + ? [ + { + name: 'baseline-correlation', + setup(builder) { + builder.onLoad( + { filter: /hermes-(cron-run-content|run-correlation)\.ts$/ }, + async (args) => ({ + contents: await readFile( + join(baselineDirectory, basename(args.path)), + 'utf8' + ), + loader: 'ts' + }) + ) + } + } + ] + : [] + }) + readers.push({ mode, ...createRequire(import.meta.url)(bundle) }) + } + if (readers.length === 2) { + let seed = 92817 + const random = (max) => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return seed % max + } + const pool = [ + null, + '', + 'invalid', + '20260101_000000', + '20260101_000200', + '20260102_000000', + '20260103_000000', + '20260101_240000' + ] + for (let trial = 0; trial < 200; trial++) { + const sessions = Array.from({ length: random(70) }, (_, i) => ({ + kind: 'session', + id: `session-${i}`, + job_id: 'job', + run_at: null, + run_key: pool[random(pool.length)], + output_content: `session ${i}` + })) + const outputs = Array.from({ length: random(70) }, (_, i) => ({ + kind: 'output', + id: `output-${i}`, + job_id: 'job', + run_at: null, + run_key: pool[random(pool.length)], + output_path: 'unused', + output_content: `output ${i}` + })) + for (const method of [ + 'mergeHermesOutputAndSessionRunRefs', + 'mergeHermesOutputAndSessionRuns' + ]) { + assert.deepEqual( + readers[1][method](outputs, sessions), + readers[0][method](outputs, sessions) + ) + } + } + console.log(JSON.stringify({ host, randomizedParityCases: 400 })) + } + for (const runs of [100, 1000, 5000]) { + const sessions = Array.from({ length: runs }, (_, i) => ({ + kind: 'session', + id: `session-${i}`, + job_id: 'job', + run_at: null, + run_key: key(i * 3600) + })).toReversed() + const outputs = Array.from({ length: runs }, (_, i) => ({ + kind: 'output', + id: `output-${i}`, + job_id: 'job', + run_at: null, + run_key: key(i * 3600 + 120), + output_path: 'unused' + })) + let expected + for (const reader of [...readers, ...readers.toReversed()]) { + const start = performance.now() + const result = reader.mergeHermesOutputAndSessionRunRefs(outputs, sessions) + const durationMs = performance.now() - start + assert.equal(result.length, runs) + result.forEach((row, i) => assert.equal(row.session.id, `session-${i}`)) + if (expected) { + assert.deepEqual(result, expected) + } + expected = result + console.log(JSON.stringify({ host, mode: reader.mode, runs, durationMs })) + } + } + } +} finally { + await rm(fixture, { recursive: true, force: true }) +} diff --git a/config/scripts/mobile-history-scope-paths-benchmark.mjs b/config/scripts/mobile-history-scope-paths-benchmark.mjs new file mode 100644 index 00000000000..dda318223f2 --- /dev/null +++ b/config/scripts/mobile-history-scope-paths-benchmark.mjs @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' + +const baseline = process.argv[2] +if (!baseline) { + throw new Error( + 'Usage: node config/scripts/mobile-history-scope-paths-benchmark.mjs ' + ) +} +const file = 'mobile/src/agent-history/agent-history-scope-paths.ts' +async function load(contents) { + const result = await build({ + stdin: { contents, loader: 'ts', resolveDir: dirname(resolve(file)) }, + bundle: true, + platform: 'node', + format: 'esm', + write: false, + logLevel: 'silent', + tsconfigRaw: {} + }) + return ( + await import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) + ).deriveMobileAiVaultScopePaths +} +const arms = { + before: await load(execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8' })), + after: await load(readFileSync(file, 'utf8')) +} +const iterations = 200 +const results = [] +for (const [count, unique] of [ + [1, 1], + [16, 16], + [64, 64], + [1000, 32] +]) { + for (const root of ['/home/ada/café/project', 'C:\\Users\\ada\\café\\project']) { + const rows = Array.from({ length: count }, (_, index) => ({ + worktreeId: `w-${index}`, + repoId: 'repo', + path: `${root}/workspace-${index % unique}` + })) + const expected = arms.before('project', rows[0], rows) + assert.deepEqual(arms.after('project', rows[0], rows), expected) + const samples = { before: [], after: [] } + function run(arm) { + let length = 0 + const start = performance.now() + for (let i = 0; i < iterations; i++) { + length += arms[arm]('project', rows[0], rows).length + } + const elapsed = performance.now() - start + assert.equal(length, iterations * expected.length) + return elapsed / iterations + } + for (const arm of ['before', 'after']) { + run(arm) + } + for (const pair of buildCounterbalancedSchedule(10, 'before', 'after')) { + for (const arm of pair) { + samples[arm].push(run(arm)) + } + } + const median = (values) => { + const sorted = [...values].sort((a, b) => a - b) + return (sorted[4] + sorted[5]) / 2 + } + results.push({ + count, + unique, + root, + beforeMs: median(samples.before), + afterMs: median(samples.after), + samples + }) + } +} +console.log( + JSON.stringify( + { baseline, node: process.version, platform: process.platform, iterations, results }, + null, + 2 + ) +) diff --git a/config/scripts/run-headless-serve-shutdown-docker.mjs b/config/scripts/run-headless-serve-shutdown-docker.mjs index d8dcdd345ad..4c367b5f7c4 100755 --- a/config/scripts/run-headless-serve-shutdown-docker.mjs +++ b/config/scripts/run-headless-serve-shutdown-docker.mjs @@ -11,6 +11,22 @@ const signalTarget = valueAfter('--signal-target') ?? 'app' const entrypoint = valueAfter('--entrypoint') ?? 'app' const intDelivery = valueAfter('--int-delivery') ?? 'foreground-process-group' const launcherExecOverlay = args.includes('--launcher-exec-overlay') +const allEntrypoints = args.includes('--all-entrypoints') +if ( + allEntrypoints && + ['--entrypoint', '--signal-target', '--int-delivery', '--launcher-exec-overlay'].some((flag) => + args.includes(flag) + ) +) { + fail('--all-entrypoints cannot be combined with individual case options') +} +const cases = allEntrypoints + ? [ + { entrypoint: 'app', signalTarget: 'app', intDelivery: 'foreground-process-group' }, + { entrypoint: 'launcher', signalTarget: 'app', intDelivery: 'foreground-process-group' }, + { entrypoint: 'appimage', signalTarget: 'serving-electron', intDelivery: 'pid' } + ] + : [{ entrypoint, signalTarget, intDelivery }] if (!appImageArg) { fail('Usage: run-headless-serve-shutdown-docker.mjs --appimage /path/to/orca.AppImage') } @@ -98,50 +114,52 @@ try { ].join(' && ') ]) - console.log( - JSON.stringify({ - type: 'appimage_under_test', - appImage, - sha256, - platform, - signalTarget, - entrypoint, - intDelivery, - launcherExecOverlay - }) - ) const failedSignals = [] - for (const signal of ['INT', 'TERM']) { - const result = docker( - [ - 'run', - '--rm', - '--init', - '--platform', + for (const { entrypoint, signalTarget, intDelivery } of cases) { + console.log( + JSON.stringify({ + type: 'appimage_under_test', + appImage, + sha256, platform, - '--shm-size', - '256m', - '--name', - `orca-headless-serve-shutdown-${signal.toLowerCase()}-${suffix}`, - '-e', - `ORCA_SIGNAL_TARGET=${signalTarget}`, - '-e', - `ORCA_TEST_ENTRYPOINT=${entrypoint}`, - '-e', - `ORCA_INT_DELIVERY=${intDelivery}`, - '-v', - `${appImage}:/input/orca.AppImage:ro`, - '-v', - `${artifactVolume}:/artifacts:ro`, - image, - signal - ], - { allowFailure: true } + signalTarget, + entrypoint, + intDelivery, + launcherExecOverlay + }) ) - process.stdout.write(result.stdout) - process.stderr.write(result.stderr) - if (result.status !== 0) { - failedSignals.push(`${signal}:${result.status}`) + for (const signal of ['INT', 'TERM']) { + const result = docker( + [ + 'run', + '--rm', + '--init', + '--platform', + platform, + '--shm-size', + '256m', + '--name', + `orca-headless-serve-shutdown-${entrypoint}-${signal.toLowerCase()}-${suffix}`, + '-e', + `ORCA_SIGNAL_TARGET=${signalTarget}`, + '-e', + `ORCA_TEST_ENTRYPOINT=${entrypoint}`, + '-e', + `ORCA_INT_DELIVERY=${intDelivery}`, + '-v', + `${appImage}:/input/orca.AppImage:ro`, + '-v', + `${artifactVolume}:/artifacts:ro`, + image, + signal + ], + { allowFailure: true } + ) + process.stdout.write(result.stdout) + process.stderr.write(result.stderr) + if (result.status !== 0) { + failedSignals.push(`${entrypoint}:${signal}:${result.status}`) + } } } if (failedSignals.length > 0) { diff --git a/config/scripts/session-search-query-benchmark.ts b/config/scripts/session-search-query-benchmark.ts new file mode 100644 index 00000000000..1471a0a17bd --- /dev/null +++ b/config/scripts/session-search-query-benchmark.ts @@ -0,0 +1,192 @@ +import { rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { + createSessionParseStats, + parseAgentSessionFileCached, + resetSessionParseCacheForTests +} from '../../src/main/ai-vault/session-scanner-parse-cache' +import { resetTranscriptConsumersForTests } from '../../src/main/ai-vault/session-transcript-consumers' +import { SessionSearchEngine } from '../../src/main/ai-vault-search/session-search-engine' +import type { + SessionSearchRequest, + SessionSearchScope +} from '../../src/main/ai-vault-search/session-search-engine-types' +import { registerSessionSearchIndexConsumer } from '../../src/main/ai-vault-search/session-search-index-consumer' +import { SessionSearchStore } from '../../src/main/ai-vault-search/session-search-store' +import type SyncDatabase from '../../src/main/sqlite/sync-database' +import { + writeSyntheticTranscriptCorpus, + type SyntheticCorpus, + type SyntheticCorpusOptions +} from '../../src/main/ai-vault-search/session-search-synthetic-corpus' +import { sessionCandidate } from '../../src/main/ai-vault-search/session-search-transcript-fixtures' + +// What a query costs, and what the session candidate limit buys. Everything +// runs through the real store and the real engine over a synthetic corpus; +// never point this at a real transcript tree. + +const WARMUP = 5 +const SAMPLES = 25 + +// One query per rung the ladder can take, plus the two shapes that skip it. +const QUERIES: { name: string; request: SessionSearchRequest }[] = [ + { name: 'phrase', request: { query: '"terminal reattach"' } }, + { name: 'identifier', request: { query: 'resolveTerminalPath' } }, + { name: 'path', request: { query: 'src/main/ai-vault/session-transcript-reader.ts' } }, + { name: 'prose', request: { query: 'why is the daemon snapshot stale' } }, + { name: 'typo', request: { query: 'reattahc worktre' } }, + { name: 'common-term', request: { query: 'index' } }, + { name: 'operator-only', request: { query: 'repo:app-3' } }, + { name: 'scoped', request: { query: 'worktree', filters: { scopePaths: ['/repo/app-3'] } } } +] + +type Timing = { p50: number; p95: number } + +function percentile(sorted: readonly number[], fraction: number): number { + const at = Math.min(sorted.length - 1, Math.floor(sorted.length * fraction)) + return Math.round((sorted[at] ?? 0) * 100) / 100 +} + +function timing(samples: number[]): Timing { + const sorted = [...samples].sort((left, right) => left - right) + return { p50: percentile(sorted, 0.5), p95: percentile(sorted, 0.95) } +} + +function time(engine: SessionSearchEngine, request: SessionSearchRequest): number { + const started = performance.now() + engine.search(request) + return performance.now() - started +} + +async function indexCorpus( + options: SyntheticCorpusOptions +): Promise<{ corpus: SyntheticCorpus; db: SyncDatabase; release: () => void }> { + resetSessionParseCacheForTests() + const corpus = await writeSyntheticTranscriptCorpus(options) + const store = new SessionSearchStore(join(corpus.root, 'index.sqlite'), (error) => { + throw error + }) + const unregister = registerSessionSearchIndexConsumer(store) + const stats = createSessionParseStats() + for (const path of corpus.files) { + await parseAgentSessionFileCached( + await sessionCandidate('claude', path), + process.platform, + stats + ) + } + return { + corpus, + // The handle a composed reader gets. Every read here is one synchronous + // statement, which is the contract that comes with it. + db: store.connection, + release: () => { + unregister() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + store.close() + } + } +} + +/** Per-query and overall latency for one scope. */ +function scopeReport(db: SyncDatabase, scope: SessionSearchScope): Record { + const engine = new SessionSearchEngine(db) + const everything: number[] = [] + const perQuery: Record = {} + for (const { name, request } of QUERIES) { + const scoped = { ...request, scope } + for (let run = 0; run < WARMUP; run++) { + engine.search(scoped) + } + const samples = Array.from({ length: SAMPLES }, () => time(engine, scoped)) + everything.push(...samples) + const result = engine.search(scoped) + perQuery[name] = { ...timing(samples), hits: result.hits.length, route: result.planner.route } + } + return { ...timing(everything), perQuery } +} + +/** + * The candidate limit only costs anything once there are more matching sessions + * than the limit, so this runs over many short sessions rather than the wide + * corpus above. Limits are interleaved sample by sample: run back to back, the + * first configuration pays for every page the OS cache had not seen yet and the + * ordering alone moves p95 by more than the limit does. + */ +function candidateSweep(db: SyncDatabase, limits: readonly number[]): Record { + const request: SessionSearchRequest = { query: 'index', limit: 20 } + const engines = new Map( + limits.map((limit) => [limit, new SessionSearchEngine(db, { sessionCandidateLimit: limit })]) + ) + const samples = new Map(limits.map((limit) => [limit, [] as number[]])) + for (let run = 0; run < WARMUP; run++) { + for (const engine of engines.values()) { + engine.search(request) + } + } + for (let run = 0; run < SAMPLES; run++) { + for (const limit of limits) { + samples.get(limit)!.push(time(engines.get(limit)!, request)) + } + } + const report: Record = {} + for (const limit of limits) { + const result = engines.get(limit)!.search(request) + report[String(limit)] = { + ...timing(samples.get(limit)!), + truncated: result.truncated.candidates, + // Pages a caller could walk before the limit stops handing out sessions. + reachablePages: Math.ceil(limit / (request.limit ?? 20)) + } + } + return report +} + +const wide = await indexCorpus({ sessions: Number(process.env.SESSIONS ?? 40) }) +let report: string +try { + const scope = { + all: scopeReport(wide.db, 'all'), + conversation: scopeReport(wide.db, 'conversation') + } + wide.release() + await rm(wide.corpus.root, { recursive: true, force: true }) + + // Many short sessions: what makes the candidate limit binding is the session + // count, not the byte count. + const many = await indexCorpus({ sessions: 2500, turnsPerSession: 1, seed: 7 }) + try { + report = JSON.stringify( + { + scopeCorpus: { + sessions: wide.corpus.files.length, + transcriptMb: Math.round((wide.corpus.transcriptBytes / 1024 / 1024) * 100) / 100, + messages: wide.corpus.messageCount + }, + scope, + candidateCorpus: { + sessions: many.corpus.files.length, + transcriptMb: Math.round((many.corpus.transcriptBytes / 1024 / 1024) * 100) / 100 + }, + candidateSweep: candidateSweep(many.db, [200, 600, 1200, 2400]) + }, + null, + 2 + ) + } finally { + many.release() + await rm(many.corpus.root, { recursive: true, force: true }) + } +} catch (error) { + await rm(wide.corpus.root, { recursive: true, force: true }) + throw error +} + +// Why a file as well as stdout: a runner that intercepts console output +// (vitest does) would otherwise swallow the whole report. +const out = process.env.BENCH_OUT +if (out) { + await writeFile(out, `${report}\n`) +} +console.log(report) diff --git a/config/scripts/session-search-scope-benchmark.ts b/config/scripts/session-search-scope-benchmark.ts new file mode 100644 index 00000000000..306387cbdda --- /dev/null +++ b/config/scripts/session-search-scope-benchmark.ts @@ -0,0 +1,205 @@ +import { rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { + createSessionParseStats, + parseAgentSessionFileCached, + resetSessionParseCacheForTests +} from '../../src/main/ai-vault/session-scanner-parse-cache' +import { resetTranscriptConsumersForTests } from '../../src/main/ai-vault/session-transcript-consumers' +import { SessionSearchEngine } from '../../src/main/ai-vault-search/session-search-engine' +import type { + SessionSearchRequest, + SessionSearchScope +} from '../../src/main/ai-vault-search/session-search-engine-types' +import { registerSessionSearchIndexConsumer } from '../../src/main/ai-vault-search/session-search-index-consumer' +import { SessionSearchStore } from '../../src/main/ai-vault-search/session-search-store' +import { sessionCandidate } from '../../src/main/ai-vault-search/session-search-transcript-fixtures' +import type SyncDatabase from '../../src/main/sqlite/sync-database' +import { writeToolHeavyCorpus, type ToolHeavyCorpus } from './session-search-tool-heavy-corpus' + +// What each scope costs on an index the size of a real transcript tree. +// +// The 10.5 MB corpus in `session-search-query-benchmark.ts` sizes the route +// ladder; this one sizes the corpus. `conversation` is a column filter over the +// one FTS table rather than a second table of its own, and the whole cost of +// that decision is how much of `messages_fts` a conversation query has to read +// past — which is set by how much of a transcript is tool output. +// +// Synthetic, always: this must never be pointed at a real transcript. + +const WARMUP = 5 + +/** Conversation-shaped queries; every term is one the prose actually uses. */ +const QUERIES = [ + 'terminal reattach', + 'stale snapshot', + 'daemon cursor', + 'worktree index', + 'publish transaction', + 'relay daemon', + 'session cursor', + 'because stale', + 'terminal worktree', + 'index snapshot', + 'reattach cursor', + 'transaction relay', + 'snapshot session', + 'daemon publish', + 'worktree terminal', + 'cursor index', + 'stale relay', + 'session transaction', + 'publish snapshot', + 'reattach daemon' +] + +async function indexCorpus( + corpus: ToolHeavyCorpus +): Promise<{ db: SyncDatabase; release: () => void }> { + resetSessionParseCacheForTests() + const store = new SessionSearchStore(join(corpus.root, 'index.sqlite'), (error) => { + throw error + }) + const unregister = registerSessionSearchIndexConsumer(store) + const stats = createSessionParseStats() + for (const path of corpus.files) { + await parseAgentSessionFileCached( + await sessionCandidate('claude', path), + process.platform, + stats + ) + } + return { + // The store's own handle, which is what a composed reader gets: every + // retrieval is one synchronous statement, so nothing pins a WAL snapshot. + db: store.connection, + release: () => { + unregister() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + store.close() + } + } +} + +type Timing = { p50: number; p95: number } + +function timing(samples: readonly number[]): Timing { + const sorted = [...samples].sort((left, right) => left - right) + const at = (fraction: number): number => { + const index = Math.min(sorted.length - 1, Math.floor(sorted.length * fraction)) + return Math.round((sorted[index] ?? 0) * 100) / 100 + } + return { p50: at(0.5), p95: at(0.95) } +} + +/** + * The query sets, one per rung of the ladder the engine may take. + * + * Which rung each one reaches is not forced, it is observed: samples are + * bucketed by the route the engine reports, so the table says what was measured + * rather than what was intended, and a query that lands on a different rung + * than expected shows up as a bucket rather than as a wrong number. + */ +function queries(): string[] { + const run = (index: number, length: number): string => + Array.from({ length }, (_unused, step) => QUERIES[(index + step) % QUERIES.length]).join(' ') + return [ + // Two terms, unquoted: not literal, so straight to OR. + ...QUERIES, + // Two terms, quoted: literal, and on this corpus any two of fourteen words + // sit next to each other somewhere, so the phrase rung answers. + ...QUERIES.map((query) => `"${query}"`), + // Eight terms, quoted: an ordered run that long does not occur in 105 MB of + // draws from fourteen words, so the phrase rung misses and AND answers. + ...QUERIES.map((_query, index) => `"${run(index, 4)}"`) + ] +} + +type Bucket = { samples: number[]; hits: number } + +/** + * Both scopes over the same queries, interleaved scope by scope: run back to + * back, the first one pays for every page the OS cache had not seen and the + * ordering moves p95 more than the scope does. + */ +function scopeReport(db: SyncDatabase): Record { + const engine = new SessionSearchEngine(db) + const scopes: SessionSearchScope[] = ['all', 'conversation'] + const requests: SessionSearchRequest[] = queries().map((query) => ({ query })) + const buckets = new Map() + for (let run = 0; run < WARMUP; run++) { + for (const scope of scopes) { + for (const request of requests) { + engine.search({ ...request, scope }) + } + } + } + for (const request of requests) { + for (const scope of scopes) { + const started = performance.now() + const result = engine.search({ ...request, scope }) + const elapsed = performance.now() - started + const key = `${result.planner.route}/${scope}` + const bucket = buckets.get(key) ?? { samples: [], hits: 0 } + bucket.samples.push(elapsed) + bucket.hits += result.hits.length + buckets.set(key, bucket) + } + } + const report: Record = {} + for (const [key, bucket] of [...buckets].sort(([left], [right]) => left.localeCompare(right))) { + report[key] = { ...timing(bucket.samples), samples: bucket.samples.length, hits: bucket.hits } + } + return report +} + +/** Bytes the FTS table occupies, which is the cost the deleted second table saved. */ +function indexBytes(db: SyncDatabase): Record | { unavailable: string } { + try { + const sum = (where: string, ...values: string[]): number => + Number( + ( + db + .prepare(`SELECT COALESCE(SUM(pgsize),0) AS bytes FROM dbstat ${where}`) + .get(...values) as { bytes: number } + ).bytes + ) + return { total: sum(''), messagesFts: sum('WHERE name LIKE ?', 'messages_fts%') } + } catch { + // dbstat is a compile-time option; the latency numbers stand without it. + return { unavailable: 'no dbstat' } + } +} + +const corpus = await writeToolHeavyCorpus({ + targetBytes: Number(process.env.CORPUS_MB ?? 100) * 1024 * 1024, + toolShare: Number(process.env.TOOL_SHARE ?? 0.9) +}) +let report: string +const indexed = await indexCorpus(corpus) +try { + report = JSON.stringify( + { + corpus: { + sessions: corpus.files.length, + transcriptMb: Math.round((corpus.transcriptBytes / 1024 / 1024) * 100) / 100, + toolShareOfMessageText: + Math.round((corpus.toolBytes / (corpus.toolBytes + corpus.proseBytes)) * 1000) / 1000 + }, + indexBytes: indexBytes(indexed.db), + route: scopeReport(indexed.db) + }, + null, + 2 + ) +} finally { + indexed.release() + await rm(corpus.root, { recursive: true, force: true }) +} + +const out = process.env.BENCH_OUT +if (out) { + await writeFile(out, `${report}\n`) +} +console.log(report) diff --git a/config/scripts/session-search-tool-heavy-corpus.ts b/config/scripts/session-search-tool-heavy-corpus.ts new file mode 100644 index 00000000000..050535a00cf --- /dev/null +++ b/config/scripts/session-search-tool-heavy-corpus.ts @@ -0,0 +1,152 @@ +import { mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// The corpus the scope benchmark runs over. Written here rather than by +// `session-search-synthetic-corpus.ts` because what it costs to answer a +// conversation query out of the one FTS table turns on the property that +// generator fixes: how much of a transcript is tool output. +// +// Synthetic, always. This must never be pointed at a real transcript. + +const PROSE = [ + 'terminal', + 'reattach', + 'worktree', + 'the', + 'index', + 'cursor', + 'publish', + 'transaction', + 'relay', + 'daemon', + 'snapshot', + 'because', + 'stale', + 'session' +] +// Tool output is paths, hashes and log lines — and the same words the +// conversation uses, because a `rg` over this repository prints them. That +// overlap is what the benchmark turns on: it is what makes a conversation +// term's posting list carry rows the column filter then has to discard. A tool +// vocabulary disjoint from the prose would leave nothing to discard and measure +// the wrong thing. +const TOOL_ONLY = [ + 'src/main/ai-vault/session-transcript-reader.ts', + 'node_modules/.pnpm/typescript@5.9.2', + '0x00007ff8', + 'ENOENT', + 'drwxr-xr-x', + '2026-09-10T00:00:00.000Z', + 'sha256:9f2c1a', + 'chunk-VHQ4NWQK.js', + 'warning:', + 'resolveTerminalPath', + 'byteOffset', + 'MAX_RETRIES' +] +// Half the tool tokens are conversation words. Deliberately pessimistic: the +// more of a query term lives in `tool_text`, the more the column filter costs, +// so a number measured here holds on a real transcript tree. +const TOOL = [...PROSE, ...TOOL_ONLY] + +function mulberry32(seed: number): () => number { + let state = seed >>> 0 + return () => { + state = (state + 0x6d2b79f5) >>> 0 + let t = Math.imul(state ^ (state >>> 15), 1 | state) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +function words(random: () => number, vocabulary: readonly string[], count: number): string { + const out: string[] = [] + for (let index = 0; index < count; index++) { + out.push(vocabulary[Math.floor(random() * vocabulary.length)]!) + } + return out.join(' ') +} + +export type ToolHeavyCorpus = { + root: string + files: string[] + transcriptBytes: number + toolBytes: number + proseBytes: number +} + +/** + * Claude JSONL transcripts whose tool output is `toolShare` of the message text. + * One turn is a user question, an assistant answer, a tool call and its output; + * only the last one grows with the share. + */ +export async function writeToolHeavyCorpus(args: { + targetBytes: number + toolShare: number + seed?: number +}): Promise { + const random = mulberry32(args.seed ?? 11) + const root = await mkdtemp(join(tmpdir(), 'orca-search-convfts-')) + const files: string[] = [] + const proseWordsPerTurn = 160 + // Tool and prose words are not the same length, so the share is over bytes. + const proseBytesPerTurn = proseWordsPerTurn * 6 + const toolWordCount = Math.max( + 1, + Math.round((proseBytesPerTurn * args.toolShare) / (1 - args.toolShare) / 22) + ) + let transcriptBytes = 0 + let toolBytes = 0 + let proseBytes = 0 + for (let session = 0; transcriptBytes < args.targetBytes; session++) { + const sessionId = `00000000-0000-4000-8000-${String(session).padStart(12, '0')}` + const lines: string[] = [] + for (let turn = 0; turn < 40; turn++) { + const at = new Date(1740000000000 + turn * 60_000).toISOString() + const question = words(random, PROSE, 40) + const answer = words(random, PROSE, proseWordsPerTurn - 40) + const output = words(random, TOOL, toolWordCount) + proseBytes += Buffer.byteLength(question) + Buffer.byteLength(answer) + toolBytes += Buffer.byteLength(output) + lines.push( + JSON.stringify({ + type: 'user', + sessionId, + timestamp: at, + cwd: `/repo/app-${session % 7}`, + gitBranch: 'main', + message: { role: 'user', content: question } + }), + JSON.stringify({ + type: 'assistant', + sessionId, + timestamp: at, + message: { + role: 'assistant', + model: 'claude-fable-5', + content: [ + { type: 'text', text: answer }, + { type: 'tool_use', name: 'Bash', input: { command: 'rg needle' } } + ] + } + }), + JSON.stringify({ + type: 'user', + sessionId, + timestamp: at, + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: output }] + } + }) + ) + } + const path = join(root, `${sessionId}.jsonl`) + const body = `${lines.join('\n')}\n` + await writeFile(path, body) + transcriptBytes += Buffer.byteLength(body) + files.push(path) + } + return { root, files, transcriptBytes, toolBytes, proseBytes } +} diff --git a/config/scripts/workflow-ref-mirror-case-safety.test.mjs b/config/scripts/workflow-ref-mirror-case-safety.test.mjs index 31366f5e489..497a58653a0 100644 --- a/config/scripts/workflow-ref-mirror-case-safety.test.mjs +++ b/config/scripts/workflow-ref-mirror-case-safety.test.mjs @@ -1,7 +1,10 @@ -import { readFileSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' import { describe, expect, it } from 'vitest' import { parse } from 'yaml' +import { runProcessSync } from '../../src/shared/child-process/run-process' const projectDir = resolve(import.meta.dirname, '../..') @@ -15,13 +18,74 @@ const REF_MIRRORS = [ ] describe('ref-mirroring vet steps', () => { - it('keeps the full-history adhoc checkout on the same case-safe backend', () => { + it.each(['daily', 'hourly', 'adhoc'])('%s builds only need the current commit', (channel) => { + const job = readWorkflow(`.github/workflows/${channel}-mac-build.yml`).jobs[ + `build-${channel}-mac` + ] + const checkout = job.steps.find((step) => step.uses === 'actions/checkout@v6') + expect(checkout.with['fetch-depth']).toBe(1) + expect(job.steps.some((step) => step.run?.includes('gh release list'))).toBe(true) + expect( + job.steps.some((step) => step.run?.includes('ORCA_PUBLISHED_VERSIONS="$published"')) + ).toBe(true) + }) + + it('retains release-cut history for version reservation and retry ancestry', () => { + const checkout = readWorkflow('.github/workflows/release-cut.yml').jobs.cut.steps.find( + (step) => step.uses === 'actions/checkout@v6' + ) + expect(checkout.with['fetch-depth']).toBe(0) + }) + + it('resolves identical dev identities in full and depth-one checkouts without local tags', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-checkout-identity-')) + const source = join(directory, 'source') + const shallow = join(directory, 'shallow') + const run = (program, args, cwd) => { + const result = runProcessSync({ program, args, cwd }) + expect(result.code, result.stderr).toBe(0) + return result.stdout.trim() + } + const git = (args, cwd = directory) => run('git', args, cwd) + try { + git(['init', source]) + git(['config', 'user.name', 'CI test'], source) + git(['config', 'user.email', 'ci@example.invalid'], source) + writeFileSync(join(source, 'package.json'), JSON.stringify({ version: '1.4.165-rc.0' })) + git(['add', 'package.json'], source) + git(['-c', 'commit.gpgsign=false', 'commit', '-m', 'initial'], source) + git(['tag', 'v1.4.167'], source) + git(['-c', 'commit.gpgsign=false', 'commit', '--allow-empty', '-m', 'head'], source) + git(['clone', '--depth=1', '--no-tags', pathToFileURL(source).href, shallow]) + expect(git(['rev-list', '--count', 'HEAD'], shallow)).toBe('1') + expect(git(['tag', '--list'], shallow)).toBe('') + const script = ` + const result = []; + for (const [channel, exported] of [['daily', 'Daily'], ['hourly', 'Hourly'], ['adhoc', 'Adhoc']]) { + const module = await import(${JSON.stringify(pathToFileURL(join(projectDir, 'config/scripts/')).href)} + channel + '-build-version.mjs'); + const date = new Date('2026-09-12T00:00:00Z'); + result.push(channel === 'adhoc' + ? module.getAdhocBuildIdentity(date, 'branch', ['v1.4.167']) + : module['get' + exported + 'BuildIdentity'](date, { publishedVersions: ['v1.4.167'], releaseNames: [] })); + } + process.stdout.write(JSON.stringify(result)); + ` + const identities = (cwd) => run(process.execPath, ['--input-type=module', '-e', script], cwd) + expect(identities(shallow)).toBe(identities(source)) + expect( + JSON.parse(identities(shallow)).every((identity) => identity.version.startsWith('1.4.168-')) + ).toBe(true) + } finally { + rmSync(directory, { recursive: true, force: true }) + } + }) + + it('checks out only the vetted commit without remirroring refs', () => { const steps = readWorkflow('.github/workflows/adhoc-mac-build.yml').jobs['build-adhoc-mac'] .steps const checkout = steps.find((step) => step.name === 'Checkout the requested ref') - expect(checkout.env.GIT_DEFAULT_REF_FORMAT).toBe('reftable') expect(checkout.with.ref).toBe('${{ steps.vetted.outputs.sha }}') - expect(checkout.with['fetch-depth']).toBe(0) + expect(checkout.with['fetch-depth']).toBe(1) expect(checkout.with['persist-credentials']).toBe(false) }) diff --git a/config/scripts/workflow-ref-reachability.test.mjs b/config/scripts/workflow-ref-reachability.test.mjs index d71c3094c56..84a0b167986 100644 --- a/config/scripts/workflow-ref-reachability.test.mjs +++ b/config/scripts/workflow-ref-reachability.test.mjs @@ -95,7 +95,7 @@ describe('release ref trust with case-twin names', () => { expect(result.stdout).toContain('Refusing to build PR ref') }) - it('preserves both case variants in the subsequent full-history checkout', async () => { + it('checks out the vetted SHA shallowly without mirroring case-twin refs again', async () => { const checkout = join(directory, 'checkout') const env = { ...identity, ...macCheckout.env } await git(['init', checkout], env) @@ -105,21 +105,15 @@ describe('release ref trust with case-twin names', () => { checkout, 'fetch', '--no-tags', + `--depth=${macCheckout.with['fetch-depth']}`, repository, - '+refs/heads/*:refs/remotes/origin/*', - '+refs/tags/*:refs/tags/*' + upper ], env ) await git(['-C', checkout, 'checkout', '--detach', upper], env) - for (const [ref, sha] of [ - ['refs/remotes/origin/Fix', upper], - ['refs/remotes/origin/fix', lower], - ['refs/tags/Release', upper], - ['refs/tags/release', lower] - ]) { - expect(await git(['-C', checkout, 'rev-parse', `${ref}^{commit}`], env)).toBe(sha) - } expect(await git(['-C', checkout, 'rev-parse', 'HEAD'], env)).toBe(upper) + expect(await git(['-C', checkout, 'rev-list', '--count', 'HEAD'], env)).toBe('1') + expect(await git(['-C', checkout, 'for-each-ref', '--format=%(refname)'], env)).toBe('') }) }) diff --git a/config/vitest.config.ts b/config/vitest.config.ts index 55333230101..beaf82f4e12 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -1,5 +1,6 @@ import { resolve } from 'node:path' import { defineConfig } from 'vitest/config' +import TimingSequencer from './scripts/ci-unit-sequencer.mjs' const windowsTestWorkerOptions = process.platform === 'win32' ? { maxWorkers: 4 } : {} @@ -15,6 +16,9 @@ export default defineConfig({ }, test: { environment: 'node', + ...(process.env.ORCA_BALANCE_UNIT_SHARDS === '1' + ? { sequence: { sequencer: TimingSequencer } } + : {}), // Why: Node 26's undefined Web Storage globals prevent Vitest from installing happy-dom's. // Why --expose-gc: retention tests need a deterministic collection point to measure what a queue really holds. execArgv: ['--no-experimental-webstorage', '--expose-gc'], diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index dce3559fd10..68c938d4238 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 48m + + downloads: 49m @@ -15,7 +15,7 @@ downloads downloads - 48m - 48m + 49m + 49m diff --git a/docs/reference/agent-session-search-query-tuning.md b/docs/reference/agent-session-search-query-tuning.md new file mode 100644 index 00000000000..9e18097cb9e --- /dev/null +++ b/docs/reference/agent-session-search-query-tuning.md @@ -0,0 +1,219 @@ +# Agent session search: query tuning + +What a search costs, and what the knobs in `src/main/ai-vault-search/session-search-engine.ts` +buy. Every number here comes from `config/scripts/session-search-query-benchmark.ts` +over the synthetic corpus in `session-search-synthetic-corpus.ts`, except the +`conversation_fts` shoot-out, which writes its own corpus because the answer +turns on how much of a transcript is tool output. Nothing in this file was +measured against a real transcript, and neither benchmark must ever be pointed +at one. + +## Running it + +The benchmark is a top-level-await module that imports the main-process tree by +extensionless path, so it needs a bundler-backed runner rather than bare `node`: + +```sh +cat > src/main/ai-vault-search/zz-bench.test.ts <<'EOF' +import { it } from 'vitest' +it('runs', { timeout: 1_800_000 }, async () => { + await import('../../../config/scripts/session-search-query-benchmark') +}) +EOF +BENCH_OUT=/tmp/ss-query-bench.json pnpm test src/main/ai-vault-search/zz-bench.test.ts +rm src/main/ai-vault-search/zz-bench.test.ts +``` + +The `conversation_fts` shoot-out below runs the same way, importing +`config/scripts/session-search-conversation-fts-benchmark` instead, with +`CORPUS_MB` and `TOOL_SHARE` to size and shape its corpus. `config/scripts` is +not inside any typecheck project, so while that throwaway test exists `tsc` +reports TS6307 for each script it pulls in; delete it and the run is clean +again. + +`BENCH_OUT` exists because vitest intercepts `console.log`; the report is written +to that path as well as printed. + +## Scope: what the second FTS table buys a reader + +Corpus: 40 synthetic Claude transcripts, 10.5 MB, 9,600 messages, indexed through +the real store. Eight queries, one per rung of the route ladder plus the two +shapes that skip it; 5 warm-up runs and 25 samples each. Apple silicon, warm page +cache, machine otherwise idle. Milliseconds, and p95 over 25 samples moves +several milliseconds run to run if anything else is competing for the disk. + +| Scope | p50 | p95 | +| -------------- | ---- | ---- | +| `all` | 7.22 | 8.94 | +| `conversation` | 5.33 | 7.86 | + +Per query, `all` then `conversation` (p50 / p95): + +| Query | `all` | `conversation` | +| ------------------------------------------------ | ------------ | -------------- | +| `"terminal reattach"` (phrase) | 5.24 / 8.42 | 2.97 / 3.24 | +| `resolveTerminalPath` (identifier) | 7.55 / 8.94 | 6.47 / 6.72 | +| `src/main/…/session-transcript-reader.ts` (path) | 8.69 / 10.12 | 7.78 / 8.04 | +| `why is the daemon snapshot stale` (prose) | 7.84 / 8.57 | 5.90 / 7.01 | +| `reattahc worktre` (typo repair) | 7.30 / 7.39 | 5.53 / 5.89 | +| `index` (common term) | 5.45 / 5.66 | 3.81 / 4.02 | +| `repo:app-3` (operator only) | 0.12 / 0.16 | 0.10 / 0.10 | +| `worktree` scoped to one cwd | 1.47 / 1.63 | 1.25 / 1.49 | + +Reading it: + +- `conversation` is about 1.4x faster at p50 and 1.1x at p95, and it is a column + filter over the same table rather than a table of its own. Narrowing to the + two prose columns is what buys the gap: fewer postings to score. It is also + the scope where a match is something a person wrote rather than something a + tool printed. +- A `scopePaths` query is the cheapest real search on the page. It is the one + narrowing SQL can express exactly, so it seeks `sessions_cwd_key` and hands + ranking a small candidate set. +- The operator-only figure is a floor, not a typical cost. `repo:` and `path:` + are applied in JS over retrieved rows (see `session-search-row-filter` for why + they cannot be pushed into SQL), so their cost tracks how many sessions the + walk has to read before it fills a candidate set. This corpus has 40 sessions, + which is one page of that walk; an index where few sessions match the operator + will read up to the ceiling in `session-search-retrieval` instead. + +## What the conversation scope costs at real corpus size + +`conversation` was a second FTS table holding a copy of the two prose columns. +It is a column filter now — `{user_text assistant_text}: (…)` with bm25 weights +that zero the other two — and PR 2 deleted the table on the strength of the +shoot-out this section used to hold: the filter came in at 1.16-1.36x the p95 of +the dedicated table, under the 2x bar, while the table cost a tenth of the index +to maintain. What follows is what the shipped schema actually does, measured +again on the same corpus after the table went and tool rows were capped. + +Corpus: Claude transcripts from `config/scripts/session-search-tool-heavy-corpus.ts`, +105 MB, indexed through the real store, at two points in the 80-97% band a real +transcript tree sits in. Half the tokens in tool output are words the +conversation also uses, so a conversation term really does have postings the +filter must discard. Twenty queries per rung, both scopes interleaved query by +query, warm cache; `config/scripts/session-search-scope-benchmark.ts`, run twice. + +| Tool share | Rung | `all` p50 / p95 | `conversation` p50 / p95 | +| ---------- | ------ | --------------- | ------------------------ | +| 86% | phrase | 16.69 / 17.48 | 13.08 / 13.52 | +| 86% | or | 31.91 / 35.74 | 22.25 / 23.87 | +| 86% | and | 70.04 / 74.00 | 53.47 / 59.39 | +| 93% | phrase | 9.14 / 13.36 | 7.23 / 8.51 | +| 93% | or | 16.46 / 18.70 | 12.34 / 14.88 | +| 93% | and | 39.65 / 43.44 | 31.05 / 32.92 | + +Three things to read out of it. + +**The filter is a win, not a cost.** Every rung is faster narrow than wide, by +1.2x to 1.4x at p50. The shoot-out compared the filter against a table built for +exactly this query; against the wide table it replaces, it does what the second +table did, which is read fewer postings. + +**The `and` rung is where the corpus size shows.** Those queries are eight terms, +chosen so no ordered run that long occurs and the phrase rung has to miss; a +real two-term AND sits nearer the phrase row. It is also the noisiest: the +second run's p95 reached 140 ms on one bucket, which is what twenty samples of a +70 ms query buys. Read the p50 column. + +**The index is far smaller than the shoot-out's was.** 57 MB at 93% tool output +and 103 MB at 86%, against roughly 150 MB for `messages_fts` alone before PR 2 +capped an indexed tool row at 3,072 characters. Most of a tool-heavy transcript +is now not in the index at all, which moves every number above and is the larger +effect of the two. + +What is **not** measured here is relevance, and the column filter does carry one +ranking difference the deleted table did not. FTS5's bm25 normalises by the +whole row's length and has no per-column length, so two rows with identical +prose score differently when one also holds tool output. The rowid set is +unchanged, which is what the deletion was decided on; the order within it can +move. `session-search-engine.test.ts` pins the direction. + +## `sessionCandidateLimit` + +The reviewer's F13: this is a tunable default, not a constant. It bounds how many +sessions the SQL hands ranking, so it bounds both retrieval cost and how deep a +caller can page before the answer simply stops. + +The limit only costs anything once more sessions match than the limit allows, so +this is measured over a second corpus: 2,500 one-turn transcripts, 10.9 MB, every +one of them matching the query. Limits are interleaved sample by sample, because +run back to back the first configuration pays for every page the OS cache had not +seen and the ordering alone moves p95 further than the limit does. + +| Limit | p50 | p95 | Pages of 20 a caller can reach | +| ----- | ----- | ----- | ------------------------------ | +| 200 | 6.85 | 7.21 | 10 | +| 600 | 7.93 | 8.36 | 30 | +| 1200 | 9.55 | 10.53 | 60 | +| 2400 | 12.32 | 13.45 | 120 | + +600 is the default: it costs about 16% over 200 at p50 and buys three times the +reachable depth, and the curve only turns steep past 1200. A host with a much +larger index can raise it; the result's `truncated.candidates` says when the limit +was the thing that cut the answer, so a caller never has to guess. + +What is **not** measured here is relevance. These numbers say what a limit costs, +not what it retrieves. The MRR figures quoted in the BM25 weights +(`session-search-retrieval.ts`) and in the identifier shadow column +(`session-search-identifier-split.ts`) come from the original retrieval shoot-out +on real transcripts and are not reproducible from this repository. Any change to +the limit justified on relevance grounds needs an eval set, not this benchmark. + +## What typo repair costs + +The repair is the one rung whose cost tracks the size of the vocabulary rather +than the size of a result. It only runs for a term the scope has no posting for, +so an ordinary query never pays it; a query of nonsense pays it once per term. + +Measured over a synthetic vocabulary of 1.6 M distinct terms, every term in two +rows so none is filtered out: + +| Query | p50 | +| -------------------------------------- | ------ | +| one known term (no repair) | 11 ms | +| one unknown term | 10 ms | +| 39 unknown 12-character terms (480 ch) | 387 ms | +| 12 unknown 40-character terms | 99 ms | + +Two things follow. The cost is linear in unknown terms and in vocabulary size, +and `search` is synchronous, so a 512-character query of nonsense holds the +thread for a third of a second on an index that large. And the scoped-count fix +made this cheaper rather than dearer — it was 737 ms before — because ordering +the vocabulary scan by term drops the sort that ordering by `doc` required, and +the counts it added are at most eight bounded probes per prefix. A cap on +unknown terms per query is recorded as a follow-up in the split plan. + +## Page warmup, dropped + +PR 2 deferred `warm()` — a sliced read of `messages` that pulls its pages into +the OS cache before the first query — to whoever knew which pages a read +touches. It is not re-added here, for two reasons. The measurement that +justified it (first query 1.3 s to 0.45 s) was on a 4 GB index, and neither +corpus in this file is within an order of magnitude of that, so PR 4 cannot +show a win: removing the call moved the 10.5 MB corpus's p50 by less than the +run-to-run spread. And it is a cancellable background pass, which needs an owner +with a lifecycle; a query library that holds no timers has nothing to hang the +`stopped()` on, and a fire-and-forget async read from a synchronous `search` is +a rejection nothing can supervise. It belongs with the indexer in PR 3b, which +already owns starting and stopping work. + +## Not settled here + +Which process may open, unlink and rebuild the index is PR 3b's decision. A +second handle that finds an older schema version replaces the file while a live +store keeps answering from the unlinked inode, and this PR is what first makes +that reachable, because it is the first thing that reads. What PR 4 does is +refuse to make it worse. The engine restores its derived vocabulary and generation +triggers before a search. A missing `messages_fts` fails clearly; the connection +owner must rebuild the source index. There is no degraded-search capability state +or query logging. Logging can be added by a caller when an evaluation consumer exists. + +Each search checks the generation before retrieval and after its final content +read. A concurrent commit rejects the page with `stale-generation`, including a +first page without a cursor. The caller can retry from page one. No long-lived +read transaction is needed, and a mixed page is never returned as a valid snapshot. + +Repository/path operators are applied before a phrase or AND route is accepted. +Candidate truncation remains explicit, including when an earlier route reached +its cap but had no eligible sessions. diff --git a/docs/reference/windows-process-enumeration.md b/docs/reference/windows-process-enumeration.md index fac8f7c58d1..685103ba9ba 100644 --- a/docs/reference/windows-process-enumeration.md +++ b/docs/reference/windows-process-enumeration.md @@ -583,8 +583,8 @@ breakaway hands the whole tree its escape. The per-PTY job therefore omits `BREAKAWAY_OK` whenever `msys-2.0.dll` or `cygwin1.dll` sits on the shell's DLL search path — beside the executable, or under `usr/bin` for Git's `bin` launcher. Native shells keep explicit breakaway. Denying it costs Cygwin -nothing, because it *pre-checks* the limit rather than retrying, so no spawn -fails; but a *native* program that passes `CREATE_BREAKAWAY_FROM_JOB` itself +nothing, because it _pre-checks_ the limit rather than retrying, so no spawn +fails; but a _native_ program that passes `CREATE_BREAKAWAY_FROM_JOB` itself inside such a pane now gets `ERROR_ACCESS_DENIED`. `nohup` and `disown` are unaffected — they are Cygwin signal/session concepts, unrelated to job membership. The daemon's host job is unchanged. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 90dc637c204..d900e6cba16 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -10,7 +10,7 @@ import packageJson from './package.json' with { type: 'json' } const BUNDLED_MAIN_DEPENDENCIES = new Set([ '@xterm/headless', '@xterm/addon-serialize', - 'psl', + 'tldts', // Why: Windows NSIS deploys app.asar before external resources; bootstrap must // not race the later resources/node_modules copy. 'zod' @@ -291,7 +291,7 @@ export const electronViteConfig: UserConfig = { preload: { build: { externalizeDeps: { - exclude: ['@electron-toolkit/preload', 'zod'] + exclude: ['zod'] } } }, diff --git a/mobile/app.config.js b/mobile/app.config.js new file mode 100644 index 00000000000..e4bb110089f --- /dev/null +++ b/mobile/app.config.js @@ -0,0 +1,32 @@ +// Why this file exists: a bare "expo-notifications" plugin entry writes +// `aps-environment: development` into the iOS entitlements, while push-token.ts +// reports `production` for every non-__DEV__ build. A TestFlight or App Store build +// would then register a production APNs token against a sandbox entitlement, and the +// gateway's pushes would be accepted by Apple and delivered nowhere. Deriving the +// mode from an env var the release workflow sets makes the two agree by construction +// instead of relying on the export step to rewrite the entitlement. +// +// app.json stays the source for everything else: Expo reads it first and hands it to +// this function, so the fastlane version/buildNumber rewrite still flows through. +const APS_ENVIRONMENT = + process.env.ORCA_IOS_APS_ENVIRONMENT === 'production' ? 'production' : 'development' + +module.exports = ({ config }) => ({ + ...config, + ios: { + ...config.ios, + entitlements: { ...config.ios?.entitlements, 'aps-environment': APS_ENVIRONMENT } + }, + plugins: (config.plugins ?? []).map((plugin) => + plugin === 'expo-notifications' + ? [ + 'expo-notifications', + { + enableBackgroundRemoteNotifications: true, + mode: APS_ENVIRONMENT, + icon: './assets/notification-icon.png' + } + ] + : plugin + ) +}) diff --git a/mobile/app.json b/mobile/app.json index fc36687d74f..6121923f775 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -75,10 +75,12 @@ "allowBackup": false, "permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"], "package": "com.stably.orca.mobile", - "versionCode": 16 + "versionCode": 16, + "googleServicesFile": "./google-services.json" }, "plugins": [ "expo-router", + "expo-notifications", "./plugins/android-respect-rotation-lock.js", [ "expo-splash-screen", diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 9080cdedcf9..c65008db7ce 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -1,6 +1,10 @@ +import { startAndroidForegroundPushPresentation } from '../src/notifications/android-foreground-push' +import { registerPushDismissalTask } from '../src/notifications/push-background-dismissal' +import { readNativeNotificationData } from '../src/notifications/native-notification-data' +import { setNotificationViewingWorkspace } from '../src/notifications/notification-viewing-policy' import { useCallback, useEffect, useRef } from 'react' import { View, StyleSheet } from 'react-native' -import { Stack, useRouter } from 'expo-router' +import { Stack, useRouter, useGlobalSearchParams, usePathname } from 'expo-router' import { StatusBar } from 'expo-status-bar' import * as SplashScreen from 'expo-splash-screen' import * as Notifications from 'expo-notifications' @@ -10,6 +14,13 @@ import { OrcaLogo } from '../src/components/OrcaLogo' import { RpcClientProvider } from '../src/transport/client-context' import { getNotificationNavigationTarget } from '../src/notifications/notification-routing' import { useOpenNotificationRoute } from '../src/notifications/use-open-notification-route' +import { + isRemotePushTrigger, + pushNotificationRouteData, + foregroundNotificationBehavior +} from '../src/notifications/push-receive' +import { startPushTokenSync } from '../src/notifications/push-registration' +import { ensureDesktopNotificationChannel } from '../src/notifications/desktop-notification-channel' import { loadHostCatalog } from '../src/transport/host-store' import { extractPairingCodeFromUrl } from '../src/transport/pairing' import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery' @@ -19,22 +30,29 @@ import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing // between the native splash and the first React paint. SplashScreen.preventAutoHideAsync() -// Why: without this, expo-notifications silently drops notifications when -// the app is in the foreground. Setting all three to true makes iOS/Android -// display the banner, play the sound, and show the badge even while the -// app is active. This runs once at module load time before any notification -// is scheduled. +// Why at boot and not only on subscribe: the gateway's FCM payload targets the +// 'orca-desktop' channel, and a background push can land before any socket has +// connected. Android drops a notification whose channel does not exist yet. +void ensureDesktopNotificationChannel().catch(() => {}) +void registerPushDismissalTask().catch(() => {}) + +// Register before scheduling so foreground delivery uses the same suppression policy. Notifications.setNotificationHandler({ - handleNotification: async () => ({ - shouldShowBanner: true, - shouldShowList: true, - shouldPlaySound: true, - shouldSetBadge: false - }) + handleNotification: foregroundNotificationBehavior }) export default function RootLayout() { const router = useRouter() + const pathname = usePathname() + const { hostId, worktreeId } = useGlobalSearchParams<{ hostId?: string; worktreeId?: string }>() + useEffect(() => { + setNotificationViewingWorkspace( + pathname.includes('/session/') && typeof hostId === 'string' && typeof worktreeId === 'string' + ? { hostId, worktreeId } + : null + ) + return () => setNotificationViewingWorkspace(null) + }, [pathname, hostId, worktreeId]) const openNotificationRoute = useOpenNotificationRoute() const handledNotificationIdsRef = useRef>(new Set()) @@ -44,6 +62,11 @@ export default function RootLayout() { void recoverMobileRelayPairing() }, []) + // Why: a rolled APNs/FCM token stops delivering silently, so every paired host + // has to be re-registered with the new one as soon as the provider hands it over. + useEffect(() => startPushTokenSync(), []) + useEffect(() => startAndroidForegroundPushPresentation(), []) + // Why: route `orca://pair?...` deep links to the confirm screen so // the same pairing flow runs whether the link arrived via QR scan, // paste, AirDrop, Messages, or `xcrun simctl openurl`. getInitialURL @@ -94,9 +117,18 @@ export default function RootLayout() { } } - async function getNavigationTarget(data: unknown) { + async function getNavigationTarget(notification: Notifications.Notification) { const hosts = await loadHostCatalog().catch(() => null) - return getNotificationNavigationTarget(data, { + const data = readNativeNotificationData(notification.request) + // A gateway push names its host by key fingerprint, not by this device's hostId. + // With no catalog to resolve against, such a push stays unrouted instead of + // falling back to whatever hostId its raw data carries. + const routeData = pushNotificationRouteData( + data, + hosts ?? [], + isRemotePushTrigger(notification.request.trigger) + ) + return getNotificationNavigationTarget(routeData, { knownHostIds: hosts ? new Set(hosts.map((host) => host.id)) : undefined, credentialStatusByHostId: hosts ? new Map(hosts.map((host) => [host.id, host.credentialStatus])) @@ -124,7 +156,7 @@ export default function RootLayout() { } } - const target = await getNavigationTarget(response.notification.request.content.data) + const target = await getNavigationTarget(response.notification) clearLastNotificationResponse() if (disposed) { return diff --git a/mobile/app/mobile-onboarding.tsx b/mobile/app/mobile-onboarding.tsx index 50957a465fc..213a5c982e5 100644 --- a/mobile/app/mobile-onboarding.tsx +++ b/mobile/app/mobile-onboarding.tsx @@ -22,7 +22,7 @@ import { saveDefaultSessionView, type MobileSessionView } from '../src/storage/session-view-preferences' -import { savePushNotificationsEnabled } from '../src/storage/preferences' +import { setRemotePushEnabled } from '../src/notifications/push-registration' const SLIDE_DURATION_MS = 280 @@ -127,7 +127,7 @@ function MobileOnboardingFlow({ setError(null) try { const enabled = choice === 'enable' ? await ensureNotificationPermissions() : false - await savePushNotificationsEnabled(enabled) + await setRemotePushEnabled(enabled) advanceOrContinue() } catch { setError('Notification settings could not be updated. Try again.') diff --git a/mobile/app/notifications.tsx b/mobile/app/notifications.tsx index d6566f66ac7..4bf9f08c25e 100644 --- a/mobile/app/notifications.tsx +++ b/mobile/app/notifications.tsx @@ -1,3 +1,5 @@ +import { NotificationDisplayTest } from '../src/settings/notification-display-test' +import { NativeNotificationDeliverySettings } from '../src/settings/native-notification-delivery-settings' import { useRouter } from 'expo-router' import NotificationsScreen from '../src/settings/notification-settings-screen' import { nativeNotificationSettingsOperations } from '../src/settings/native-notification-settings-operations' @@ -7,6 +9,14 @@ export default function NativeNotificationsRoute() { router.back()} - /> + description="Get agent alerts even when the app is closed. Delivered through Orca’s push service and Apple or Google." + > + {(enabled) => ( + <> + + router.push('/troubleshoot')} /> + + )} + ) } diff --git a/mobile/assets/notification-icon.png b/mobile/assets/notification-icon.png new file mode 100644 index 00000000000..774110aca7e Binary files /dev/null and b/mobile/assets/notification-icon.png differ diff --git a/mobile/google-services.json b/mobile/google-services.json new file mode 100644 index 00000000000..4120a97dafc --- /dev/null +++ b/mobile/google-services.json @@ -0,0 +1,39 @@ +{ + "project_info": { + "project_number": "120364513935", + "project_id": "onorca-cloud", + "storage_bucket": "onorca-cloud.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:120364513935:android:1d951dc430aeb9bc664efa", + "android_client_info": { + "package_name": "com.stably.orca.mobile" + } + }, + "oauth_client": [ + { + "client_id": "120364513935-evfa8502bp5r9hn7afhd9i03oibs8223.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyBmT_w0OUQSiVfxblx-F0qlRvGkBBkTNQU" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "120364513935-evfa8502bp5r9hn7afhd9i03oibs8223.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + } + ], + "configuration_version": "1" +} diff --git a/mobile/index.js b/mobile/index.js new file mode 100644 index 00000000000..489f950c650 --- /dev/null +++ b/mobile/index.js @@ -0,0 +1,3 @@ +// Headless notification launches do not mount the router layout. +import './src/notifications/push-background-dismissal' +import 'expo-router/entry' diff --git a/mobile/modules/orca-notification-dismissal/expo-module.config.json b/mobile/modules/orca-notification-dismissal/expo-module.config.json new file mode 100644 index 00000000000..dbf5942dbd5 --- /dev/null +++ b/mobile/modules/orca-notification-dismissal/expo-module.config.json @@ -0,0 +1,7 @@ +{ + "platforms": ["apple"], + "apple": { + "modules": ["OrcaNotificationDismissalModule"], + "appDelegateSubscribers": ["OrcaNotificationDismissalSubscriber"] + } +} diff --git a/mobile/modules/orca-notification-dismissal/ios/OrcaNotificationDismissal.podspec b/mobile/modules/orca-notification-dismissal/ios/OrcaNotificationDismissal.podspec new file mode 100644 index 00000000000..7e2aee8ebd7 --- /dev/null +++ b/mobile/modules/orca-notification-dismissal/ios/OrcaNotificationDismissal.podspec @@ -0,0 +1,15 @@ +Pod::Spec.new do |s| + s.name = 'OrcaNotificationDismissal' + s.version = '0.0.1' + s.summary = 'Native notification dismissal and sequence fencing' + s.description = s.summary + s.license = { :type => 'MIT' } + s.author = 'Orca' + s.homepage = 'https://onorca.dev' + s.source = { :git => 'https://github.com/stablyai/orca.git' } + s.platforms = { :ios => '15.1' } + s.swift_version = '5.9' + s.static_framework = true + s.dependency 'ExpoModulesCore' + s.source_files = '**/*.swift' +end diff --git a/mobile/modules/orca-notification-dismissal/ios/OrcaNotificationDismissalModule.swift b/mobile/modules/orca-notification-dismissal/ios/OrcaNotificationDismissalModule.swift new file mode 100644 index 00000000000..f86fe8bf891 --- /dev/null +++ b/mobile/modules/orca-notification-dismissal/ios/OrcaNotificationDismissalModule.swift @@ -0,0 +1,14 @@ +import ExpoModulesCore + +public class OrcaNotificationDismissalModule: Module { + public func definition() -> ModuleDefinition { + Name("OrcaNotificationDismissal") + AsyncFunction("remember") { (payload: [String: Any]) in + if let identity = PushDismissalIdentity(payload) { PushDismissalLedger.shared.remember(identity) } + } + AsyncFunction("wasDismissed") { (payload: [String: Any]) -> Bool in + guard let identity = PushDismissalIdentity(payload) else { return false } + return PushDismissalLedger.shared.contains(identity) + } + } +} diff --git a/mobile/modules/orca-notification-dismissal/ios/OrcaNotificationDismissalSubscriber.swift b/mobile/modules/orca-notification-dismissal/ios/OrcaNotificationDismissalSubscriber.swift new file mode 100644 index 00000000000..2bd7d939888 --- /dev/null +++ b/mobile/modules/orca-notification-dismissal/ios/OrcaNotificationDismissalSubscriber.swift @@ -0,0 +1,26 @@ +import ExpoModulesCore +import UserNotifications + +public class OrcaNotificationDismissalSubscriber: ExpoAppDelegateSubscriber { + public func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { + guard let payload = userInfo["orca"] as? [String: Any], + payload["kind"] as? String == "dismiss", let fence = PushDismissalIdentity(payload) + else { completionHandler(.noData); return } + PushDismissalLedger.shared.remember(fence) + let center = UNUserNotificationCenter.current() + center.getDeliveredNotifications { notifications in + let ids = notifications.compactMap { notification -> String? in + guard let data = notification.request.content.userInfo["orca"] as? [String: Any], + data["hostFingerprint"] as? String == fence.hostFingerprint, + PushDismissalLedger.shared.containsNotification(data) else { return nil } + return notification.request.identifier + } + center.removeDeliveredNotifications(withIdentifiers: ids) + completionHandler(ids.isEmpty ? .noData : .newData) + } + } +} diff --git a/mobile/modules/orca-notification-dismissal/ios/PushDismissalLedger.swift b/mobile/modules/orca-notification-dismissal/ios/PushDismissalLedger.swift new file mode 100644 index 00000000000..a147b09d599 --- /dev/null +++ b/mobile/modules/orca-notification-dismissal/ios/PushDismissalLedger.swift @@ -0,0 +1,67 @@ +import Foundation +import CoreFoundation + +struct PushDismissalIdentity: Codable { + let hostFingerprint: String + let notificationId: String + let notificationEpoch: String + let notificationSeq: Int64 + + init?(_ value: [String: Any]) { + guard let host = value["hostFingerprint"] as? String, !host.isEmpty, host.count <= 512, + let id = value["notificationId"] as? String, !id.isEmpty, id.count <= 2048, + let epoch = value["notificationEpoch"] as? String, !epoch.isEmpty, epoch.count <= 128, + let seq = value["notificationSeq"] as? NSNumber, + CFGetTypeID(seq) != CFBooleanGetTypeID(), seq.doubleValue.isFinite, + seq.doubleValue >= 0, seq.doubleValue <= 9_007_199_254_740_991, + seq.doubleValue.rounded(.down) == seq.doubleValue else { return nil } + hostFingerprint = host; notificationId = id; notificationEpoch = epoch + notificationSeq = seq.int64Value + } + + func matches(_ other: PushDismissalIdentity) -> Bool { + hostFingerprint == other.hostFingerprint && notificationId == other.notificationId && + notificationEpoch == other.notificationEpoch + } +} + +final class PushDismissalLedger { + static let shared = PushDismissalLedger() + private struct Entry: Codable { let identity: PushDismissalIdentity; let expiresAt: TimeInterval } + private let defaults: UserDefaults + private let lock = NSLock() + private let storageKey = "orca.pushDismissals.v1" + init(defaults: UserDefaults = .standard) { self.defaults = defaults } + + private func read(now: TimeInterval) -> [Entry] { + guard let data = defaults.data(forKey: storageKey), + let entries = try? JSONDecoder().decode([Entry].self, from: data) else { return [] } + return entries.filter { $0.expiresAt > now } + } + + func remember(_ identity: PushDismissalIdentity, now: TimeInterval = Date().timeIntervalSince1970) { + lock.lock(); defer { lock.unlock() } + let entries = read(now: now) + let previous = entries.first { $0.identity.matches(identity) } + let newest = (previous?.identity.notificationSeq ?? -1) > identity.notificationSeq + ? previous!.identity : identity + // Keep every live fence: count-based eviction lets delayed alerts reappear. + let next = entries.filter { !$0.identity.matches(identity) } + + [Entry(identity: newest, expiresAt: now + 86400)] + if let data = try? JSONEncoder().encode(next) { + defaults.set(data, forKey: storageKey) + } + } + + func contains(_ identity: PushDismissalIdentity, now: TimeInterval = Date().timeIntervalSince1970) -> Bool { + lock.lock(); defer { lock.unlock() } + return read(now: now).contains { + $0.identity.matches(identity) && $0.identity.notificationSeq >= identity.notificationSeq + } + } + + func containsNotification(_ payload: [String: Any], now: TimeInterval = Date().timeIntervalSince1970) -> Bool { + guard let identity = PushDismissalIdentity(payload) else { return false } + return contains(identity, now: now) + } +} diff --git a/mobile/modules/orca-notification-dismissal/package.json b/mobile/modules/orca-notification-dismissal/package.json new file mode 100644 index 00000000000..6710e7adbf6 --- /dev/null +++ b/mobile/modules/orca-notification-dismissal/package.json @@ -0,0 +1,5 @@ +{ + "name": "orca-notification-dismissal", + "version": "0.0.1", + "private": true +} diff --git a/mobile/modules/orca-notification-dismissal/tests/PushDismissalLedgerChecks.swift b/mobile/modules/orca-notification-dismissal/tests/PushDismissalLedgerChecks.swift new file mode 100644 index 00000000000..04e1809ea29 --- /dev/null +++ b/mobile/modules/orca-notification-dismissal/tests/PushDismissalLedgerChecks.swift @@ -0,0 +1,44 @@ +import Foundation +@main struct PushDismissalLedgerChecks { + static func main() { + let suite = "orca.qa.dismissal." + UUID().uuidString + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + func payload(_ seq: Int, _ host: String = "qa-host", _ epoch: String = "qa-epoch", _ id: String = "qa-alert") -> [String: Any] { + ["hostFingerprint": host, "notificationId": id, "notificationEpoch": epoch, "notificationSeq": seq] + } + func identity(_ seq: Int, _ host: String = "qa-host", _ epoch: String = "qa-epoch", _ id: String = "qa-alert") -> PushDismissalIdentity { + PushDismissalIdentity(payload(seq, host, epoch, id))! + } + let ledger = PushDismissalLedger(defaults: defaults) + ledger.remember(identity(2), now: 100) + ledger.remember(identity(1), now: 101) + let restored = PushDismissalLedger(defaults: defaults) + precondition(restored.contains(identity(1), now: 102)) + precondition(restored.contains(identity(2), now: 102)) + precondition(!restored.contains(identity(3), now: 102)) + precondition(!restored.contains(identity(1, "other"), now: 102)) + precondition(!restored.contains(identity(1, "qa-host", "other"), now: 102)) + precondition(!restored.contains(identity(1, "qa-host", "qa-epoch", "other"), now: 102)) + precondition(!restored.contains(identity(1), now: 86501)) + precondition(PushDismissalIdentity(["hostFingerprint":"h", "notificationId":"n", "notificationEpoch":"e", "notificationSeq":true]) == nil) + precondition(restored.containsNotification(payload(1), now: 102)) + precondition(!restored.containsNotification(payload(3), now: 102)) + precondition(!restored.containsNotification(payload(1, "other"), now: 102)) + precondition(!restored.containsNotification(payload(1, "qa-host", "other"), now: 102)) + precondition(!restored.containsNotification(payload(1, "qa-host", "qa-epoch", "other"), now: 102)) + precondition(!restored.containsNotification(["hostFingerprint": "qa-host"], now: 102)) + for hosts in [1, 3] { + for index in 0..<520 { + ledger.remember(identity(2, "host-\(index % hosts)", "epoch-\(hosts)", "note-\(index)"), now: 200) + } + let reopened = PushDismissalLedger(defaults: defaults) + for index in [0, 1, 519] { + precondition(reopened.contains(identity(2, "host-\(index % hosts)", "epoch-\(hosts)", "note-\(index)"), now: 201)) + precondition(!reopened.contains(identity(3, "host-\(index % hosts)", "epoch-\(hosts)", "note-\(index)"), now: 201)) + precondition(!reopened.contains(identity(2, "host-\(index % hosts)", "epoch-\(hosts)", "note-\(index)"), now: 86600)) + } + } + print("Native persisted fence: restart, ordering, identity isolation, expiry and invalid sequence checks passed") + } +} diff --git a/mobile/package.json b/mobile/package.json index 13f2f98acf5..d86c0d524ef 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -2,7 +2,7 @@ "name": "orca-mobile", "version": "0.0.1", "private": true, - "main": "expo-router/entry", + "main": "index.js", "scripts": { "start": "node scripts/start-expo.mjs", "android": "expo run:android", @@ -46,6 +46,7 @@ "expo-secure-store": "^55.0.18", "expo-splash-screen": "^55.0.25", "expo-status-bar": "^55.0.6", + "expo-task-manager": "~55.0.20", "lowlight": "^3.3.0", "lucide-react-native": "^1.14.0", "mermaid": "11.17.2", diff --git a/mobile/patches/expo-notifications@55.0.27.patch b/mobile/patches/expo-notifications@55.0.27.patch new file mode 100644 index 00000000000..e1a8d77ea8a --- /dev/null +++ b/mobile/patches/expo-notifications@55.0.27.patch @@ -0,0 +1,36 @@ +diff --git a/build/getDevicePushTokenAsync.js b/build/getDevicePushTokenAsync.js +index f0875c5eaa84d45d646edd1ad5f21a962f68ab02..d93813b2636f1ac5e09081c3207407410a404698 100644 +--- a/build/getDevicePushTokenAsync.js ++++ b/build/getDevicePushTokenAsync.js +@@ -20,8 +20,11 @@ export async function getDevicePushTokenAsync() { + else { + // Create a new Promise and clear it afterwards + nativeTokenPromise = PushTokenManager.getDevicePushTokenAsync(); +- devicePushToken = await nativeTokenPromise; +- nativeTokenPromise = null; ++ try { ++ devicePushToken = await nativeTokenPromise; ++ } finally { ++ nativeTokenPromise = null; ++ } + } + // @ts-ignore: TS thinks Platform.OS could be anything and can't decide what type is it + return { type: Platform.OS, data: devicePushToken }; +diff --git a/src/getDevicePushTokenAsync.ts b/src/getDevicePushTokenAsync.ts +index ab518dff463bc1a92052329ce5ac7c9055cbcf62..ad164f162998b5c1e218f089be82ab474727d68e 100644 +--- a/src/getDevicePushTokenAsync.ts ++++ b/src/getDevicePushTokenAsync.ts +@@ -24,8 +24,11 @@ export async function getDevicePushTokenAsync(): Promise { + } else { + // Create a new Promise and clear it afterwards + nativeTokenPromise = PushTokenManager.getDevicePushTokenAsync(); +- devicePushToken = await nativeTokenPromise; +- nativeTokenPromise = null; ++ try { ++ devicePushToken = await nativeTokenPromise; ++ } finally { ++ nativeTokenPromise = null; ++ } + } + + // @ts-ignore: TS thinks Platform.OS could be anything and can't decide what type is it diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 7cebee89eec..2bb2b314b09 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -8,12 +8,9 @@ overrides: xcode>uuid: 11.1.1 patchedDependencies: - react-native-webview@13.16.2: - hash: de6761dfa76a5491a23e49f1262566a8831cab26736a336fdffc5d4e7d05ff27 - path: patches/react-native-webview@13.16.2.patch - react-native@0.83.10: - hash: 44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d - path: patches/react-native@0.83.10.patch + expo-notifications@55.0.27: ce20843a3daad4185d7e8571788fa323ba4d11984936188790858650a61749c0 + react-native-webview@13.16.2: de6761dfa76a5491a23e49f1262566a8831cab26736a336fdffc5d4e7d05ff27 + react-native@0.83.10: 44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d importers: @@ -24,10 +21,10 @@ importers: version: 1.8.0 '@orca/expo-two-way-audio': specifier: file:./packages/expo-two-way-audio - version: file:packages/expo-two-way-audio(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + version: file:packages/expo-two-way-audio(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) '@react-native-async-storage/async-storage': specifier: ^2.2.0 - version: 2.2.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)) + version: 2.2.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8)) '@xterm/addon-unicode11': specifier: 0.10.0-beta.300 version: 0.10.0-beta.300(@xterm/xterm@6.1.0-beta.303) @@ -42,19 +39,19 @@ importers: version: 6.0.3 expo: specifier: ^55.0.30 - version: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + version: 55.0.30(09911ea01feb2f63557d787d92391924) expo-build-properties: specifier: ^55.0.18 version: 55.0.18(expo@55.0.30) expo-camera: specifier: ^55.0.23 - version: 55.0.23(@types/emscripten@1.41.5)(expo@55.0.30)(react-native-web@0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + version: 55.0.23(@types/emscripten@1.41.5)(expo@55.0.30)(react-native-web@0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) expo-clipboard: specifier: ^55.0.17 - version: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + version: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) expo-constants: specifier: ^55.0.17 - version: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)) + version: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8)) expo-crypto: specifier: ^55.0.19 version: 55.0.19(expo@55.0.30) @@ -66,7 +63,7 @@ importers: version: 55.0.17(expo@55.0.30) expo-file-system: specifier: 55.0.26 - version: 55.0.26(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)) + version: 55.0.26(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8)) expo-haptics: specifier: ^55.0.18 version: 55.0.18(expo@55.0.30) @@ -81,19 +78,19 @@ importers: version: 55.0.8(expo@55.0.30)(react@19.2.8) expo-linking: specifier: ^55.0.17 - version: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + version: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) expo-modules-core: specifier: ~55.0.25 - version: 55.0.25(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + version: 55.0.25(react-native-worklets@0.8.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) expo-network: specifier: ~55.0.18 version: 55.0.18(expo@55.0.30)(react@19.2.8) expo-notifications: specifier: ^55.0.27 - version: 55.0.27(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@6.0.3) + version: 55.0.27(patch_hash=ce20843a3daad4185d7e8571788fa323ba4d11984936188790858650a61749c0)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@6.0.3) expo-router: specifier: ^55.0.18 - version: 55.0.18(4a60a26fd685ffdcc7f556016ae4bc5e) + version: 55.0.18(98b45897562456c6c413f91e81d6c336) expo-secure-store: specifier: ^55.0.18 version: 55.0.18(expo@55.0.30) @@ -102,13 +99,16 @@ importers: version: 55.0.25(expo@55.0.30)(typescript@6.0.3) expo-status-bar: specifier: ^55.0.6 - version: 55.0.6(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + version: 55.0.6(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + expo-task-manager: + specifier: ~55.0.20 + version: 55.0.20(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8)) lowlight: specifier: ^3.3.0 version: 3.3.0 lucide-react-native: specifier: ^1.14.0 - version: 1.14.0(react-native-svg@15.15.4(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + version: 1.14.0(react-native-svg@15.15.4(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) mermaid: specifier: 11.17.2 version: 11.17.2 @@ -120,34 +120,34 @@ importers: version: 19.2.8(react@19.2.8) react-native: specifier: ^0.83.10 - version: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + version: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) react-native-gesture-handler: specifier: ^2.31.2 - version: 2.31.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + version: 2.31.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) react-native-reanimated: specifier: 4.3.4 - version: 4.3.4(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + version: 4.3.4(react-native-worklets@0.8.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) react-native-safe-area-context: specifier: ^5.7.0 - version: 5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + version: 5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) react-native-screens: specifier: ^4.24.0 - version: 4.24.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + version: 4.24.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) react-native-svg: specifier: ^15.15.4 - version: 15.15.4(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + version: 15.15.4(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) react-native-uitextview: specifier: 2.2.0 - version: 2.2.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + version: 2.2.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) react-native-web: specifier: ^0.21.2 version: 0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react-native-webview: specifier: 13.16.2 - version: 13.16.2(patch_hash=de6761dfa76a5491a23e49f1262566a8831cab26736a336fdffc5d4e7d05ff27)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + version: 13.16.2(patch_hash=de6761dfa76a5491a23e49f1262566a8831cab26736a336fdffc5d4e7d05ff27)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) react-native-worklets: specifier: ^0.8.3 - version: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + version: 0.8.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) tweetnacl: specifier: ^1.0.3 version: 1.0.3 @@ -166,7 +166,7 @@ importers: version: 19.2.14 '@types/react-native': specifier: ^0.73.0 - version: 0.73.0(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + version: 0.73.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) '@types/react-test-renderer': specifier: 19.1.0 version: 19.1.0 @@ -181,7 +181,7 @@ importers: version: 0.25.4 expo-module-scripts: specifier: ^55.0.2 - version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) + version: 55.0.2(@babel/core@7.29.7(supports-color@8.1.1))(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(esbuild@0.25.4)(eslint@9.39.4(supports-color@8.1.1))(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0)(supports-color@8.1.1))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) happy-dom: specifier: ^20.11.8 version: 20.11.8 @@ -205,7 +205,7 @@ importers: version: 8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0) vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@26.4.0)(happy-dom@20.11.8)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.11(@types/node@26.4.0)(happy-dom@20.11.8)(jsdom@20.0.3(supports-color@8.1.1))(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0)) packages: @@ -1632,8 +1632,8 @@ packages: react-native: optional: true - '@expo/dom-webview@55.0.5': - resolution: {integrity: sha512-lt3uxYOCk3wmWvtOOvsC35CKGbDAOx5C2EaY8SH1JVSfBzqmF8Cs0Xp1MPxncDPMyxpMiWx5SvvV/iLF1rJU4A==} + '@expo/dom-webview@55.0.6': + resolution: {integrity: sha512-ZNm8tiNEZysxrr36J0x4mOCGyJDcaIvL/3tMxBz0VJIJDcV19xjuJAhJQxHovu+jKx6s9tRyEAINa1mdrzV39g==} peerDependencies: expo: '*' react: '*' @@ -1669,14 +1669,6 @@ packages: '@expo/local-build-cache-provider@55.0.16': resolution: {integrity: sha512-/m2kb/+G2ryZ60FPZcuiLXzXp55p/X8QPXuU5CV/il0sSJcY0sQFICfM9ApokzHtzNQ0nDQOBUoQY3weilgP2g==} - '@expo/log-box@55.0.11': - resolution: {integrity: sha512-JQHFLWkskIbJi6cxYMjErx8lQqfFJilDQLKmdTO3m3YkdmN9GE/CrzjOfVlCG0DGEGZJ90br0pGKvGPdXNsHKw==} - peerDependencies: - '@expo/dom-webview': ^55.0.5 - expo: '*' - react: '*' - react-native: '*' - '@expo/log-box@55.0.13': resolution: {integrity: sha512-pV623uwyKjw/L1HVWOpwWOu/ISLH1+c+ESVv30alQMbEaE3cLcwcQ+UnHiAGayMBNMQwK57eckOgH40RBXHfCA==} peerDependencies: @@ -1693,8 +1685,8 @@ packages: expo: optional: true - '@expo/metro-runtime@55.0.10': - resolution: {integrity: sha512-7v+ldTvMWRa1ml83Jel9W2f8qT/NZZWrlHaEjf29nb72JTEO50+Xac9PWLo+X3LCDAAuyYuBGKYXOJwfqxV0fQ==} + '@expo/metro-runtime@55.0.12': + resolution: {integrity: sha512-EeqXrRBvChdt6+brlUkZM5749QoS7OlN7Zsn/AT8hhGV+xNKglirVRkcKQFmKqPgjgmNxfwgLJ6ddanwZ9dapg==} peerDependencies: expo: '*' react: '*' @@ -4487,6 +4479,12 @@ packages: react: '*' react-native: '*' + expo-task-manager@55.0.20: + resolution: {integrity: sha512-yxiERbkqibZYDArQ1QKbezKn7YkCxLyaDeiIO8DcyOqopBvUmXDkF3b7FtesW+YnAlg3g223geV8YiW1I4Suew==} + peerDependencies: + expo: '*' + react-native: '*' + expo-updates-interface@55.1.6: resolution: {integrity: sha512-evxNpagCkjT3lE6bGV570TFzRtKuIuLY8I37RYHoriXCJ+ZKCN1hbmklK29uAixya+BxGpeTI2K4FqYeJLvfrw==} peerDependencies: @@ -6876,6 +6874,9 @@ packages: resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} engines: {node: '>=4'} + unimodules-app-loader@55.0.5: + resolution: {integrity: sha512-2eLjtaAVQTK3EeiUAgRbfEnX78f6cMtw5Js8Ri4OcEdkrozsmvG3Wu8YVfr6kfhea17FHZkKZmO1m4dL/Ky2Bg==} + universalify@0.2.0: resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} engines: {node: '>= 4.0.0'} @@ -7233,9 +7234,9 @@ snapshots: package-manager-detector: 1.8.0 tinyexec: 1.1.2 - '@babel/cli@7.28.6(@babel/core@7.29.7)': + '@babel/cli@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@jridgewell/trace-mapping': 0.3.31 commander: 6.2.1 convert-source-map: 2.0.0 @@ -7267,20 +7268,20 @@ snapshots: '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.7': + '@babel/core@7.29.7(supports-color@8.1.1)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@8.1.1) '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -7335,52 +7336,52 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.29.3(@babel/core@7.29.7)': + '@babel/helper-create-class-features-plugin@7.29.3(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-member-expression-to-functions': 7.28.5 '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.7) + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 '@babel/traverse': 7.29.0 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@8.1.1) '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@8.1.1) + '@babel/traverse': 7.29.8(supports-color@8.1.1) semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.29.7)': + '@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.27.3 regexpu-core: 6.4.0 semver: 6.3.1 - '@babel/helper-create-regexp-features-plugin@7.29.7(@babel/core@7.29.7)': + '@babel/helper-create-regexp-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.29.7 regexpu-core: 6.4.0 semver: 6.3.1 - '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.7)': + '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) lodash.debounce: 4.0.8 resolve: 1.22.12 transitivePeerDependencies: @@ -7397,9 +7398,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-member-expression-to-functions@7.29.7': + '@babel/helper-member-expression-to-functions@7.29.7(supports-color@8.1.1)': dependencies: - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@8.1.1) '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -7411,28 +7412,28 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.29.7': + '@babel/helper-module-imports@7.29.7(supports-color@8.1.1)': dependencies: - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@8.1.1) '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.7)': + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-module-imports': 7.28.6 '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-module-imports': 7.29.7(supports-color@8.1.1) '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -7448,39 +7449,39 @@ snapshots: '@babel/helper-plugin-utils@7.29.7': {} - '@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.29.7)': + '@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-wrap-function': 7.28.6 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/helper-remap-async-to-generator@7.29.7(@babel/core@7.29.7)': + '@babel/helper-remap-async-to-generator@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-wrap-function': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/helper-wrap-function': 7.29.7(supports-color@8.1.1) + '@babel/traverse': 7.29.8(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.7)': + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-member-expression-to-functions': 7.28.5 '@babel/helper-optimise-call-expression': 7.27.1 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@8.1.1) '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -7491,9 +7492,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + '@babel/helper-skip-transparent-expression-wrappers@7.29.7(supports-color@8.1.1)': dependencies: - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@8.1.1) '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -7513,15 +7514,15 @@ snapshots: '@babel/helper-wrap-function@7.28.6': dependencies: '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@8.1.1) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-wrap-function@7.29.7': + '@babel/helper-wrap-function@7.29.7(supports-color@8.1.1)': dependencies: '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@8.1.1) '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -7550,887 +7551,887 @@ snapshots: dependencies: '@babel/types': 7.29.8 - '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5(@babel/core@7.29.7)': + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.3(@babel/core@7.29.7)': + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.3(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: - supports-color - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) transitivePeerDependencies: - supports-color - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.7)': + '@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) transitivePeerDependencies: - supports-color - '@babel/plugin-proposal-export-default-from@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-proposal-export-default-from@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-proposal-export-default-from@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-proposal-export-default-from@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7)': + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) - '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7)': + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7)': + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7)': + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7)': + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.7)': + '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-export-default-from@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-syntax-export-default-from@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-export-default-from@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-syntax-export-default-from@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-flow@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-syntax-flow@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-flow@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-syntax-flow@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-import-assertions@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-syntax-import-assertions@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)': + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7)': + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7)': + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7)': + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7)': + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7)': + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7)': + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7)': + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7)': + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7)': + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.7)': + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-async-generator-functions@7.29.0(@babel/core@7.29.7)': + '@babel/plugin-transform-async-generator-functions@7.29.0(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.7) + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-async-generator-functions@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-async-generator-functions@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7) - '@babel/traverse': 7.29.8 + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/traverse': 7.29.8(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-async-to-generator@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-async-to-generator@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-module-imports': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.7) + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-async-to-generator@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-async-to-generator@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-module-imports': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7) + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-block-scoped-functions@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-block-scoped-functions@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-block-scoping@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-block-scoping@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-block-scoping@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-block-scoping@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-class-properties@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-class-properties@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-class-properties@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-class-properties@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-class-static-block@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-class-static-block@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-classes@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-classes@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-globals': 7.28.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.7) + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-classes@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-classes@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.29.7 '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-globals': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) - '@babel/traverse': 7.29.8 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/traverse': 7.29.8(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-computed-properties@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-computed-properties@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 '@babel/template': 7.28.6 - '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.7)': + '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-dotall-regex@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-dotall-regex@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-duplicate-keys@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-duplicate-keys@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.0(@babel/core@7.29.7)': + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.0(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-dynamic-import@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-dynamic-import@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-explicit-resource-management@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-explicit-resource-management@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.7) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-exponentiation-operator@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-exponentiation-operator@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-export-namespace-from@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-export-namespace-from@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-flow-strip-types@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-flow-strip-types@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-flow': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-syntax-flow': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) - '@babel/plugin-transform-flow-strip-types@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-flow-strip-types@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-flow': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-flow': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) - '@babel/plugin-transform-for-of@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-for-of@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-for-of@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-for-of@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-function-name@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-function-name@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-json-strings@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-json-strings@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-literals@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-literals@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-logical-assignment-operators@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-logical-assignment-operators@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-member-expression-literals@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-member-expression-literals@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-modules-amd@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-modules-amd@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-systemjs@7.29.4(@babel/core@7.29.7)': + '@babel/plugin-transform-modules-systemjs@7.29.4(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-identifier': 7.28.5 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-umd@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-modules-umd@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-named-capturing-groups-regex@7.29.0(@babel/core@7.29.7)': + '@babel/plugin-transform-named-capturing-groups-regex@7.29.0(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-new-target@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-new-target@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-nullish-coalescing-operator@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-nullish-coalescing-operator@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-nullish-coalescing-operator@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-nullish-coalescing-operator@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-numeric-separator@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-numeric-separator@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-object-rest-spread@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-object-rest-spread@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.7) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.7) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-object-super@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-object-super@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.7) + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-optional-catch-binding@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-optional-catch-binding@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-optional-catch-binding@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-optional-catch-binding@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-optional-chaining@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-optional-chaining@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-optional-chaining@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-optional-chaining@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.29.7)': + '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-private-methods@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-private-methods@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-private-methods@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-private-methods@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-private-property-in-object@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-private-property-in-object@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.7) + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-private-property-in-object@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-private-property-in-object@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-property-literals@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-property-literals@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.29.7)': + '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-react-display-name@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-react-display-name@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-react-jsx@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-react-jsx@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-module-imports': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-react-jsx@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-react-jsx@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-module-imports': 7.29.7 + '@babel/helper-module-imports': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-react-pure-annotations@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-react-pure-annotations@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-regenerator@7.29.0(@babel/core@7.29.7)': + '@babel/plugin-transform-regenerator@7.29.0(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-regenerator@7.29.8(@babel/core@7.29.7)': + '@babel/plugin-transform-regenerator@7.29.8(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-regexp-modifiers@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-regexp-modifiers@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-reserved-words@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-reserved-words@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-runtime@7.29.0(@babel/core@7.29.7)': + '@babel/plugin-transform-runtime@7.29.0(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-module-imports': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 - babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.7) - babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.7) - babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.7) + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-module-imports': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.7) - babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.7) - babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.7) + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-shorthand-properties@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-shorthand-properties@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-spread@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-spread@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-sticky-regex@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-sticky-regex@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-template-literals@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-template-literals@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-typeof-symbol@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-typeof-symbol@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.7) + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@8.1.1) + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-unicode-escapes@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-unicode-escapes@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-unicode-property-regex@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-unicode-property-regex@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-unicode-regex@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-unicode-regex@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-unicode-regex@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-unicode-regex@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-unicode-sets-regex@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-unicode-sets-regex@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/preset-env@7.29.5(@babel/core@7.29.7)': + '@babel/preset-env@7.29.5(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: '@babel/compat-data': 7.29.3 - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.28.5(@babel/core@7.29.7) - '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array': 7.29.3(@babel/core@7.29.7) - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7) - '@babel/plugin-syntax-import-assertions': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.7) - '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.29.7) - '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-block-scoped-functions': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.7) - '@babel/plugin-transform-dotall-regex': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-duplicate-keys': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.7) - '@babel/plugin-transform-dynamic-import': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-explicit-resource-management': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-exponentiation-operator': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-json-strings': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-logical-assignment-operators': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-member-expression-literals': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-modules-amd': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-modules-systemjs': 7.29.4(@babel/core@7.29.7) - '@babel/plugin-transform-modules-umd': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.7) - '@babel/plugin-transform-new-target': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-numeric-separator': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-object-super': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.7) - '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-property-literals': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.29.7) - '@babel/plugin-transform-regexp-modifiers': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-reserved-words': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-typeof-symbol': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-unicode-escapes': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-unicode-property-regex': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-unicode-sets-regex': 7.28.6(@babel/core@7.29.7) - '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.7) - babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.7) - babel-plugin-polyfill-corejs3: 0.14.2(@babel/core@7.29.7) - babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.7) + '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array': 7.29.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-import-assertions': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-block-scoped-functions': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-dotall-regex': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-duplicate-keys': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-dynamic-import': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-explicit-resource-management': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-exponentiation-operator': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-json-strings': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-logical-assignment-operators': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-member-expression-literals': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-modules-amd': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-modules-systemjs': 7.29.4(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-modules-umd': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-new-target': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-numeric-separator': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-object-super': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-property-literals': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-regexp-modifiers': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-reserved-words': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-typeof-symbol': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-unicode-escapes': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-unicode-property-regex': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-unicode-sets-regex': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.7(supports-color@8.1.1)) + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + babel-plugin-polyfill-corejs3: 0.14.2(@babel/core@7.29.7(supports-color@8.1.1)) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) core-js-compat: 3.49.0 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.7)': + '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 '@babel/types': 7.29.0 esutils: 2.0.3 - '@babel/preset-react@7.28.5(@babel/core@7.29.7)': + '@babel/preset-react@7.28.5(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-react-pure-annotations': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-react-pure-annotations': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) transitivePeerDependencies: - supports-color - '@babel/preset-typescript@7.28.5(@babel/core@7.29.7)': + '@babel/preset-typescript@7.28.5(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) transitivePeerDependencies: - supports-color @@ -8458,11 +8459,11 @@ snapshots: '@babel/parser': 7.29.3 '@babel/template': 7.28.6 '@babel/types': 7.29.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/traverse@7.29.7': + '@babel/traverse@7.29.7(supports-color@8.1.1)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 @@ -8470,11 +8471,11 @@ snapshots: '@babel/parser': 7.29.7 '@babel/template': 7.29.7 '@babel/types': 7.29.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/traverse@7.29.8': + '@babel/traverse@7.29.8(supports-color@8.1.1)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.8 @@ -8482,7 +8483,7 @@ snapshots: '@babel/parser': 7.29.8 '@babel/template': 7.29.7 '@babel/types': 7.29.8 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -8680,22 +8681,22 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true - '@eslint-community/eslint-utils@4.10.1(eslint@9.39.4)': + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.4(supports-color@8.1.1))': dependencies: - eslint: 9.39.4 + eslint: 9.39.4(supports-color@8.1.1) eslint-visitor-keys: 3.4.3 - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(supports-color@8.1.1))': dependencies: - eslint: 9.39.4 + eslint: 9.39.4(supports-color@8.1.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.2': + '@eslint/config-array@0.21.2(supports-color@8.1.1)': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -8708,10 +8709,10 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.6': + '@eslint/eslintrc@3.3.6(supports-color@8.1.1)': dependencies: ajv: 6.15.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -8733,7 +8734,7 @@ snapshots: '@expo-google-fonts/material-symbols@0.4.34': {} - '@expo/cli@55.0.36(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-constants@55.0.17)(expo-font@55.0.8)(expo-router@55.0.18)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@6.0.3)': + '@expo/cli@55.0.36(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.12)(expo-constants@55.0.17)(expo-font@55.0.8)(expo-router@55.0.18)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@6.0.3)': dependencies: '@expo/code-signing-certificates': 0.0.6 '@expo/config': 55.0.21(typescript@6.0.3) @@ -8742,7 +8743,7 @@ snapshots: '@expo/env': 2.1.3 '@expo/image-utils': 0.8.17(typescript@6.0.3) '@expo/json-file': 10.2.0 - '@expo/log-box': 55.0.13(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@expo/log-box': 55.0.13(@expo/dom-webview@55.0.6)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) '@expo/metro': 55.1.2 '@expo/metro-config': 55.0.27(expo@55.0.30)(typescript@6.0.3) '@expo/osascript': 2.7.0 @@ -8750,7 +8751,7 @@ snapshots: '@expo/plist': 0.5.4 '@expo/prebuild-config': 55.0.22(expo@55.0.30)(typescript@6.0.3) '@expo/require-utils': 55.0.8(typescript@6.0.3) - '@expo/router-server': 55.0.19(@expo/metro-runtime@55.0.10)(expo-constants@55.0.17)(expo-font@55.0.8)(expo-router@55.0.18)(expo-server@55.0.12)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@expo/router-server': 55.0.19(@expo/metro-runtime@55.0.12)(expo-constants@55.0.17)(expo-font@55.0.8)(expo-router@55.0.18)(expo-server@55.0.12)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@expo/schema-utils': 55.0.5 '@expo/spawn-async': 1.8.0 '@expo/ws-tunnel': 1.0.6 @@ -8765,10 +8766,10 @@ snapshots: chalk: 4.1.2 ci-info: 3.9.0 compression: 1.8.1 - connect: 3.7.0 - debug: 4.4.3 + connect: 3.7.0(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) dnssd-advertise: 1.1.6 - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) expo-server: 55.0.12 fetch-nodeshim: 0.4.10 getenv: 2.0.0 @@ -8795,8 +8796,8 @@ snapshots: ws: 8.21.3 zod: 3.25.76 optionalDependencies: - expo-router: 55.0.18(4a60a26fd685ffdcc7f556016ae4bc5e) - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + expo-router: 55.0.18(98b45897562456c6c413f91e81d6c336) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) transitivePeerDependencies: - '@expo/dom-webview' - '@expo/metro-runtime' @@ -8821,7 +8822,7 @@ snapshots: '@expo/plist': 0.5.4 '@expo/sdk-runtime-versions': 1.0.0 chalk: 4.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) getenv: 2.0.0 glob: 13.0.6 resolve-from: 5.0.0 @@ -8839,7 +8840,7 @@ snapshots: '@expo/plist': 0.5.3 '@expo/sdk-runtime-versions': 1.0.0 chalk: 4.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) getenv: 2.0.0 glob: 13.0.6 resolve-from: 5.0.0 @@ -8893,23 +8894,23 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/devtools@55.0.3(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': + '@expo/devtools@55.0.3(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': dependencies: chalk: 4.1.2 optionalDependencies: react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) - '@expo/dom-webview@55.0.5(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': + '@expo/dom-webview@55.0.6(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) '@expo/env@2.1.3': dependencies: chalk: 4.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) getenv: 2.0.0 transitivePeerDependencies: - supports-color @@ -8917,7 +8918,7 @@ snapshots: '@expo/env@2.4.2': dependencies: chalk: 4.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) getenv: 2.0.0 transitivePeerDependencies: - supports-color @@ -8928,7 +8929,7 @@ snapshots: '@expo/spawn-async': 1.8.0 arg: 5.0.2 chalk: 4.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) getenv: 2.0.0 glob: 13.0.6 ignore: 5.3.2 @@ -8979,28 +8980,19 @@ snapshots: - supports-color - typescript - '@expo/log-box@55.0.11(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': + '@expo/log-box@55.0.13(@expo/dom-webview@55.0.6)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': dependencies: - '@expo/dom-webview': 55.0.5(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@expo/dom-webview': 55.0.6(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) anser: 1.4.10 - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) - stacktrace-parser: 0.1.11 - - '@expo/log-box@55.0.13(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': - dependencies: - '@expo/dom-webview': 55.0.5(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - anser: 1.4.10 - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) - react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) stacktrace-parser: 0.1.11 '@expo/metro-config@55.0.27(expo@55.0.30)(typescript@6.0.3)': dependencies: '@babel/code-frame': 7.29.7 - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/generator': 7.29.8 '@expo/config': 55.0.21(typescript@6.0.3) '@expo/env': 2.1.3 @@ -9009,7 +9001,7 @@ snapshots: '@expo/spawn-async': 1.8.0 browserslist: 4.28.8 chalk: 4.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) getenv: 2.0.0 glob: 13.0.6 hermes-parser: 0.32.1 @@ -9019,21 +9011,21 @@ snapshots: postcss: 8.5.25 resolve-from: 5.0.0 optionalDependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) transitivePeerDependencies: - bufferutil - supports-color - typescript - utf-8-validate - '@expo/metro-runtime@55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': + '@expo/metro-runtime@55.0.12(@expo/dom-webview@55.0.6)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': dependencies: - '@expo/log-box': 55.0.11(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@expo/log-box': 55.0.13(@expo/dom-webview@55.0.6)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) anser: 1.4.10 - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) pretty-format: 29.7.0 react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) stacktrace-parser: 0.1.11 whatwg-fetch: 3.6.20 optionalDependencies: @@ -9043,20 +9035,20 @@ snapshots: '@expo/metro@55.1.2': dependencies: - metro: 0.83.8 - metro-babel-transformer: 0.83.8 - metro-cache: 0.83.8 + metro: 0.83.8(supports-color@8.1.1) + metro-babel-transformer: 0.83.8(supports-color@8.1.1) + metro-cache: 0.83.8(supports-color@8.1.1) metro-cache-key: 0.83.8 - metro-config: 0.83.8 + metro-config: 0.83.8(supports-color@8.1.1) metro-core: 0.83.8 - metro-file-map: 0.83.8 + metro-file-map: 0.83.8(supports-color@8.1.1) metro-minify-terser: 0.83.8 metro-resolver: 0.83.8 metro-runtime: 0.83.8 - metro-source-map: 0.83.8 + metro-source-map: 0.83.8(supports-color@8.1.1) metro-symbolicate: 0.83.8 - metro-transform-plugins: 0.83.8 - metro-transform-worker: 0.83.8 + metro-transform-plugins: 0.83.8(supports-color@8.1.1) + metro-transform-worker: 0.83.8(supports-color@8.1.1) transitivePeerDependencies: - bufferutil - supports-color @@ -9099,8 +9091,8 @@ snapshots: '@expo/image-utils': 0.8.17(typescript@6.0.3) '@expo/json-file': 10.2.0 '@react-native/normalize-colors': 0.83.10 - debug: 4.4.3 - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + debug: 4.4.3(supports-color@8.1.1) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) resolve-from: 5.0.0 semver: 7.8.5 xml2js: 0.6.0 @@ -9111,8 +9103,8 @@ snapshots: '@expo/require-utils@55.0.5(typescript@5.9.3)': dependencies: '@babel/code-frame': 7.29.0 - '@babel/core': 7.29.7 - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -9121,24 +9113,24 @@ snapshots: '@expo/require-utils@55.0.8(typescript@6.0.3)': dependencies: '@babel/code-frame': 7.29.7 - '@babel/core': 7.29.7 - '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) optionalDependencies: typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@expo/router-server@55.0.19(@expo/metro-runtime@55.0.10)(expo-constants@55.0.17)(expo-font@55.0.8)(expo-router@55.0.18)(expo-server@55.0.12)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@expo/router-server@55.0.19(@expo/metro-runtime@55.0.12)(expo-constants@55.0.17)(expo-font@55.0.8)(expo-router@55.0.18)(expo-server@55.0.12)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - debug: 4.4.3 - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) - expo-constants: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)) - expo-font: 55.0.8(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + debug: 4.4.3(supports-color@8.1.1) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) + expo-constants: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8)) + expo-font: 55.0.8(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) expo-server: 55.0.12 react: 19.2.8 optionalDependencies: - '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - expo-router: 55.0.18(4a60a26fd685ffdcc7f556016ae4bc5e) + '@expo/metro-runtime': 55.0.12(@expo/dom-webview@55.0.6)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + expo-router: 55.0.18(98b45897562456c6c413f91e81d6c336) react-dom: 19.2.8(react@19.2.8) transitivePeerDependencies: - supports-color @@ -9157,11 +9149,11 @@ snapshots: '@expo/sudo-prompt@9.3.2': {} - '@expo/vector-icons@15.1.1(expo-font@55.0.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': + '@expo/vector-icons@15.1.1(expo-font@55.0.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': dependencies: - expo-font: 55.0.8(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + expo-font: 55.0.8(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) '@expo/ws-tunnel@1.0.6': {} @@ -9216,12 +9208,12 @@ snapshots: jest-util: 29.7.0 slash: 3.0.0 - '@jest/core@29.7.0': + '@jest/core@29.7.0(supports-color@8.1.1)': dependencies: '@jest/console': 29.7.0 - '@jest/reporters': 29.7.0 + '@jest/reporters': 29.7.0(supports-color@8.1.1) '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 + '@jest/transform': 29.7.0(supports-color@8.1.1) '@jest/types': 29.6.3 '@types/node': 26.4.0 ansi-escapes: 4.3.2 @@ -9230,15 +9222,15 @@ snapshots: exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@26.4.0) + jest-config: 29.7.0(@types/node@26.4.0)(supports-color@8.1.1) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 jest-resolve: 29.7.0 - jest-resolve-dependencies: 29.7.0 - jest-runner: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 + jest-resolve-dependencies: 29.7.0(supports-color@8.1.1) + jest-runner: 29.7.0(supports-color@8.1.1) + jest-runtime: 29.7.0(supports-color@8.1.1) + jest-snapshot: 29.7.0(supports-color@8.1.1) jest-util: 29.7.0 jest-validate: 29.7.0 jest-watcher: 29.7.0 @@ -9268,10 +9260,10 @@ snapshots: dependencies: jest-get-type: 29.6.3 - '@jest/expect@29.7.0': + '@jest/expect@29.7.0(supports-color@8.1.1)': dependencies: expect: 29.7.0 - jest-snapshot: 29.7.0 + jest-snapshot: 29.7.0(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -9286,21 +9278,21 @@ snapshots: '@jest/get-type@30.1.0': {} - '@jest/globals@29.7.0': + '@jest/globals@29.7.0(supports-color@8.1.1)': dependencies: '@jest/environment': 29.7.0 - '@jest/expect': 29.7.0 + '@jest/expect': 29.7.0(supports-color@8.1.1) '@jest/types': 29.6.3 jest-mock: 29.7.0 transitivePeerDependencies: - supports-color - '@jest/reporters@29.7.0': + '@jest/reporters@29.7.0(supports-color@8.1.1)': dependencies: '@bcoe/v8-coverage': 0.2.3 '@jest/console': 29.7.0 '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 + '@jest/transform': 29.7.0(supports-color@8.1.1) '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.31 '@types/node': 26.4.0 @@ -9310,9 +9302,9 @@ snapshots: glob: 7.2.3 graceful-fs: 4.2.11 istanbul-lib-coverage: 3.2.2 - istanbul-lib-instrument: 6.0.3 + istanbul-lib-instrument: 6.0.3(supports-color@8.1.1) istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 4.0.1 + istanbul-lib-source-maps: 4.0.1(supports-color@8.1.1) istanbul-reports: 3.2.0 jest-message-util: 29.7.0 jest-util: 29.7.0 @@ -9352,12 +9344,12 @@ snapshots: jest-haste-map: 29.7.0 slash: 3.0.0 - '@jest/transform@29.7.0': + '@jest/transform@29.7.0(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.31 - babel-plugin-istanbul: 6.1.1 + babel-plugin-istanbul: 6.1.1(supports-color@8.1.1) chalk: 4.1.2 convert-source-map: 2.0.0 fast-json-stable-stringify: 2.1.0 @@ -9421,11 +9413,11 @@ snapshots: '@noble/hashes@1.8.0': {} - '@orca/expo-two-way-audio@file:packages/expo-two-way-audio(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': + '@orca/expo-two-way-audio@file:packages/expo-two-way-audio(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) '@oxc-project/types@0.137.0': {} @@ -9737,178 +9729,178 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@react-native-async-storage/async-storage@2.2.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))': + '@react-native-async-storage/async-storage@2.2.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))': dependencies: merge-options: 3.0.4 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) '@react-native/assets-registry@0.83.10': {} - '@react-native/babel-plugin-codegen@0.83.10(@babel/core@7.29.7)': + '@react-native/babel-plugin-codegen@0.83.10(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/traverse': 7.29.8 - '@react-native/codegen': 0.83.10(@babel/core@7.29.7) + '@babel/traverse': 7.29.8(supports-color@8.1.1) + '@react-native/codegen': 0.83.10(@babel/core@7.29.7(supports-color@8.1.1)) transitivePeerDependencies: - '@babel/core' - supports-color - '@react-native/babel-plugin-codegen@0.83.6(@babel/core@7.29.7)': + '@react-native/babel-plugin-codegen@0.83.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/traverse': 7.29.7 - '@react-native/codegen': 0.83.6(@babel/core@7.29.7) + '@babel/traverse': 7.29.7(supports-color@8.1.1) + '@react-native/codegen': 0.83.6(@babel/core@7.29.7(supports-color@8.1.1)) transitivePeerDependencies: - '@babel/core' - supports-color - '@react-native/babel-plugin-codegen@0.85.2(@babel/core@7.29.7)': + '@react-native/babel-plugin-codegen@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/traverse': 7.29.8 - '@react-native/codegen': 0.85.2(@babel/core@7.29.7) + '@babel/traverse': 7.29.8(supports-color@8.1.1) + '@react-native/codegen': 0.85.2(@babel/core@7.29.7(supports-color@8.1.1)) transitivePeerDependencies: - '@babel/core' - supports-color - '@react-native/babel-preset@0.83.10(@babel/core@7.29.7)': + '@react-native/babel-preset@0.83.10(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-proposal-export-default-from': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-export-default-from': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-flow-strip-types': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-logical-assignment-operators': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-numeric-separator': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.7) - '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-display-name': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-regenerator': 7.29.8(@babel/core@7.29.7) - '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/plugin-proposal-export-default-from': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-export-default-from': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-flow-strip-types': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-logical-assignment-operators': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-numeric-separator': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-react-display-name': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-regenerator': 7.29.8(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/template': 7.29.7 - '@react-native/babel-plugin-codegen': 0.83.10(@babel/core@7.29.7) + '@react-native/babel-plugin-codegen': 0.83.10(@babel/core@7.29.7(supports-color@8.1.1)) babel-plugin-syntax-hermes-parser: 0.32.0 - babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7) + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7(supports-color@8.1.1)) react-refresh: 0.14.2 transitivePeerDependencies: - supports-color - '@react-native/babel-preset@0.83.6(@babel/core@7.29.7)': + '@react-native/babel-preset@0.83.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-export-default-from': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.29.7) - '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.7) - '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-logical-assignment-operators': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.7) - '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-numeric-separator': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.7) - '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.29.7) - '@babel/plugin-transform-runtime': 7.29.0(@babel/core@7.29.7) - '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-export-default-from': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-logical-assignment-operators': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-numeric-separator': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-runtime': 7.29.0(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/template': 7.28.6 - '@react-native/babel-plugin-codegen': 0.83.6(@babel/core@7.29.7) + '@react-native/babel-plugin-codegen': 0.83.6(@babel/core@7.29.7(supports-color@8.1.1)) babel-plugin-syntax-hermes-parser: 0.32.0 - babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7) + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7(supports-color@8.1.1)) react-refresh: 0.14.2 transitivePeerDependencies: - supports-color - '@react-native/babel-preset@0.85.2(@babel/core@7.29.7)': + '@react-native/babel-preset@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-proposal-export-default-from': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-export-default-from': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-flow-strip-types': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-display-name': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-regenerator': 7.29.8(@babel/core@7.29.7) - '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) - '@react-native/babel-plugin-codegen': 0.85.2(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/plugin-proposal-export-default-from': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-export-default-from': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-flow-strip-types': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-react-display-name': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-regenerator': 7.29.8(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@react-native/babel-plugin-codegen': 0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) babel-plugin-syntax-hermes-parser: 0.33.3 - babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7) + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7(supports-color@8.1.1)) react-refresh: 0.14.2 transitivePeerDependencies: - supports-color - '@react-native/codegen@0.83.10(@babel/core@7.29.7)': + '@react-native/codegen@0.83.10(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/parser': 7.29.8 glob: 7.2.3 hermes-parser: 0.32.0 @@ -9916,9 +9908,9 @@ snapshots: nullthrows: 1.1.1 yargs: 17.7.3 - '@react-native/codegen@0.83.6(@babel/core@7.29.7)': + '@react-native/codegen@0.83.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/parser': 7.29.7 glob: 7.2.3 hermes-parser: 0.32.0 @@ -9926,9 +9918,9 @@ snapshots: nullthrows: 1.1.1 yargs: 17.7.2 - '@react-native/codegen@0.85.2(@babel/core@7.29.7)': + '@react-native/codegen@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/parser': 7.29.8 hermes-parser: 0.33.3 invariant: 2.2.4 @@ -9936,17 +9928,17 @@ snapshots: tinyglobby: 0.2.17 yargs: 17.7.3 - '@react-native/community-cli-plugin@0.83.10(@react-native/metro-config@0.85.2(@babel/core@7.29.7))': + '@react-native/community-cli-plugin@0.83.10(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))': dependencies: '@react-native/dev-middleware': 0.83.10 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) invariant: 2.2.4 - metro: 0.83.7 - metro-config: 0.83.7 + metro: 0.83.7(supports-color@8.1.1) + metro-config: 0.83.7(supports-color@8.1.1) metro-core: 0.83.7 semver: 7.8.5 optionalDependencies: - '@react-native/metro-config': 0.85.2(@babel/core@7.29.7) + '@react-native/metro-config': 0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) transitivePeerDependencies: - bufferutil - supports-color @@ -9966,8 +9958,8 @@ snapshots: '@react-native/debugger-shell': 0.83.10 chrome-launcher: 0.15.2 chromium-edge-launcher: 0.2.0 - connect: 3.7.0 - debug: 4.4.3 + connect: 3.7.0(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) invariant: 2.2.4 nullthrows: 1.1.1 open: 7.4.2 @@ -9984,20 +9976,20 @@ snapshots: '@react-native/js-polyfills@0.85.2': {} - '@react-native/metro-babel-transformer@0.85.2(@babel/core@7.29.7)': + '@react-native/metro-babel-transformer@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 - '@react-native/babel-preset': 0.85.2(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@react-native/babel-preset': 0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) hermes-parser: 0.33.3 nullthrows: 1.1.1 transitivePeerDependencies: - supports-color - '@react-native/metro-config@0.85.2(@babel/core@7.29.7)': + '@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: '@react-native/js-polyfills': 0.85.2 - '@react-native/metro-babel-transformer': 0.85.2(@babel/core@7.29.7) - metro-config: 0.84.5 + '@react-native/metro-babel-transformer': 0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + metro-config: 0.84.5(supports-color@8.1.1) metro-runtime: 0.84.5 transitivePeerDependencies: - '@babel/core' @@ -10009,24 +10001,24 @@ snapshots: '@react-native/normalize-colors@0.83.10': {} - '@react-native/virtualized-lists@0.83.10(@types/react@19.2.14)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': + '@react-native/virtualized-lists@0.83.10(@types/react@19.2.14)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': dependencies: invariant: 2.2.4 nullthrows: 1.1.1 react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) optionalDependencies: '@types/react': 19.2.14 - ? '@react-navigation/bottom-tabs@7.15.11(@react-navigation/native@7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native-safe-area-context@5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native-screens@4.24.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)' - : dependencies: - '@react-navigation/elements': 2.9.15(@react-navigation/native@7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native-safe-area-context@5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - '@react-navigation/native': 7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@react-navigation/bottom-tabs@7.15.11(edc9e9b19f67aea8a6d8a2ee54babcc5)': + dependencies: + '@react-navigation/elements': 2.9.15(@react-navigation/native@7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native-safe-area-context@5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@react-navigation/native': 7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) color: 4.2.3 react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) - react-native-safe-area-context: 5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - react-native-screens: 4.24.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) + react-native-safe-area-context: 5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + react-native-screens: 4.24.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) sf-symbols-typescript: 2.2.0 transitivePeerDependencies: - '@react-native-masked-view/masked-view' @@ -10043,38 +10035,38 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.8) use-sync-external-store: 1.6.0(react@19.2.8) - '@react-navigation/elements@2.9.15(@react-navigation/native@7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native-safe-area-context@5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': + '@react-navigation/elements@2.9.15(@react-navigation/native@7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native-safe-area-context@5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': dependencies: - '@react-navigation/native': 7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@react-navigation/native': 7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) color: 4.2.3 react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) - react-native-safe-area-context: 5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) + react-native-safe-area-context: 5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) use-latest-callback: 0.2.6(react@19.2.8) use-sync-external-store: 1.6.0(react@19.2.8) - ? '@react-navigation/native-stack@7.14.12(@react-navigation/native@7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native-safe-area-context@5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native-screens@4.24.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)' - : dependencies: - '@react-navigation/elements': 2.9.15(@react-navigation/native@7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native-safe-area-context@5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - '@react-navigation/native': 7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@react-navigation/native-stack@7.14.12(edc9e9b19f67aea8a6d8a2ee54babcc5)': + dependencies: + '@react-navigation/elements': 2.9.15(@react-navigation/native@7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native-safe-area-context@5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@react-navigation/native': 7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) color: 4.2.3 react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) - react-native-safe-area-context: 5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - react-native-screens: 4.24.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) + react-native-safe-area-context: 5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + react-native-screens: 4.24.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) sf-symbols-typescript: 2.2.0 warn-once: 0.1.1 transitivePeerDependencies: - '@react-native-masked-view/masked-view' - '@react-navigation/native@7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': + '@react-navigation/native@7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': dependencies: '@react-navigation/core': 7.17.2(react@19.2.8) escape-string-regexp: 4.0.0 fast-deep-equal: 3.1.3 nanoid: 3.3.18 react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) use-latest-callback: 0.2.6(react@19.2.8) '@react-navigation/routers@7.5.3': @@ -10148,17 +10140,17 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)': + '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@26.4.0)(supports-color@8.1.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: jest-matcher-utils: 30.3.0 picocolors: 1.1.1 pretty-format: 30.3.0 react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) react-test-renderer: 19.2.8(react@19.2.8) redent: 3.0.0 optionalDependencies: - jest: 29.7.0(@types/node@26.4.0) + jest: 29.7.0(@types/node@26.4.0)(supports-color@8.1.1) '@tootallnate/once@2.0.1': {} @@ -10371,9 +10363,9 @@ snapshots: dependencies: undici-types: 8.3.0 - '@types/react-native@0.73.0(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)': + '@types/react-native@0.73.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8)': dependencies: - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) transitivePeerDependencies: - '@babel/core' - '@react-native-community/cli' @@ -10413,15 +10405,15 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3))(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3) + '@typescript-eslint/parser': 8.59.2(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.59.2 - '@typescript-eslint/type-utils': 8.59.2(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.59.2(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.2(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.59.2 - eslint: 9.39.4 + eslint: 9.39.4(supports-color@8.1.1) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -10429,15 +10421,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3)': + '@typescript-eslint/parser@8.59.2(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.59.2 '@typescript-eslint/types': 8.59.2 '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.59.2 - debug: 4.4.3 - eslint: 9.39.4 - typescript: 6.0.3 + debug: 4.4.3(supports-color@8.1.1) + eslint: 9.39.4(supports-color@8.1.1) + typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -10445,7 +10437,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) '@typescript-eslint/types': 8.59.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -10459,13 +10451,13 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.59.2(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.59.2 '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.2(eslint@9.39.4)(typescript@5.9.3) - debug: 4.4.3 - eslint: 9.39.4 + '@typescript-eslint/utils': 8.59.2(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3) + debug: 4.4.3(supports-color@8.1.1) + eslint: 9.39.4(supports-color@8.1.1) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -10479,7 +10471,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) '@typescript-eslint/types': 8.59.2 '@typescript-eslint/visitor-keys': 8.59.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.5 semver: 7.8.5 tinyglobby: 0.2.17 @@ -10488,13 +10480,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/utils@8.59.2(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(supports-color@8.1.1)) '@typescript-eslint/scope-manager': 8.59.2 '@typescript-eslint/types': 8.59.2 '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) - eslint: 9.39.4 + eslint: 9.39.4(supports-color@8.1.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -10628,9 +10620,9 @@ snapshots: acorn@8.15.0: {} - agent-base@6.0.2: + agent-base@6.0.2(supports-color@8.1.1): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -10765,13 +10757,13 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 - babel-jest@29.7.0(@babel/core@7.29.7): + babel-jest@29.7.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.7 - '@jest/transform': 29.7.0 + '@babel/core': 7.29.7(supports-color@8.1.1) + '@jest/transform': 29.7.0(supports-color@8.1.1) '@types/babel__core': 7.20.5 - babel-plugin-istanbul: 6.1.1 - babel-preset-jest: 29.6.3(@babel/core@7.29.7) + babel-plugin-istanbul: 6.1.1(supports-color@8.1.1) + babel-preset-jest: 29.6.3(@babel/core@7.29.7(supports-color@8.1.1)) chalk: 4.1.2 graceful-fs: 4.2.11 slash: 3.0.0 @@ -10782,12 +10774,12 @@ snapshots: dependencies: object.assign: 4.1.7 - babel-plugin-istanbul@6.1.1: + babel-plugin-istanbul@6.1.1(supports-color@8.1.1): dependencies: '@babel/helper-plugin-utils': 7.29.7 '@istanbuljs/load-nyc-config': 1.1.0 '@istanbuljs/schema': 0.1.6 - istanbul-lib-instrument: 5.2.1 + istanbul-lib-instrument: 5.2.1(supports-color@8.1.1) test-exclude: 6.0.0 transitivePeerDependencies: - supports-color @@ -10799,35 +10791,35 @@ snapshots: '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.28.0 - babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7): + babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1): dependencies: '@babel/compat-data': 7.29.3 - '@babel/core': 7.29.7 - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) semver: 6.3.1 transitivePeerDependencies: - supports-color - babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.29.7): + babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.7 - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) core-js-compat: 3.49.0 transitivePeerDependencies: - supports-color - babel-plugin-polyfill-corejs3@0.14.2(@babel/core@7.29.7): + babel-plugin-polyfill-corejs3@0.14.2(@babel/core@7.29.7(supports-color@8.1.1)): dependencies: - '@babel/core': 7.29.7 - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) core-js-compat: 3.49.0 transitivePeerDependencies: - supports-color - babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.7): + babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.7 - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -10849,102 +10841,102 @@ snapshots: dependencies: hermes-parser: 0.33.3 - babel-plugin-transform-flow-enums@0.0.2(@babel/core@7.29.7): + babel-plugin-transform-flow-enums@0.0.2(@babel/core@7.29.7(supports-color@8.1.1)): dependencies: - '@babel/plugin-syntax-flow': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-syntax-flow': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) transitivePeerDependencies: - '@babel/core' - babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.7): + babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.7(supports-color@8.1.1)): dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.7) - '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.7) - '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.7) - '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7) - '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.7) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.7) - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.7) - '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.7(supports-color@8.1.1)) - babel-preset-expo@55.0.21(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@55.0.30)(react-refresh@0.14.2): + babel-preset-expo@55.0.21(@babel/core@7.29.7(supports-color@8.1.1))(@babel/runtime@7.29.7)(expo@55.0.30)(react-refresh@0.14.2): dependencies: '@babel/generator': 7.29.1 '@babel/helper-module-imports': 7.28.6 - '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.7) - '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-syntax-export-default-from': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.7) - '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-runtime': 7.29.0(@babel/core@7.29.7) - '@babel/preset-react': 7.28.5(@babel/core@7.29.7) - '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7) - '@react-native/babel-preset': 0.83.6(@babel/core@7.29.7) + '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-export-default-from': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-runtime': 7.29.0(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/preset-react': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) + '@react-native/babel-preset': 0.83.6(@babel/core@7.29.7(supports-color@8.1.1)) babel-plugin-react-compiler: 1.0.0 babel-plugin-react-native-web: 0.21.2 babel-plugin-syntax-hermes-parser: 0.32.1 - babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7) - debug: 4.4.3 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7(supports-color@8.1.1)) + debug: 4.4.3(supports-color@8.1.1) react-refresh: 0.14.2 resolve-from: 5.0.0 optionalDependencies: '@babel/runtime': 7.29.7 - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) transitivePeerDependencies: - '@babel/core' - supports-color - babel-preset-expo@55.0.25(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@55.0.30)(react-refresh@0.14.2): + babel-preset-expo@55.0.25(@babel/core@7.29.7(supports-color@8.1.1))(@babel/runtime@7.29.7)(expo@55.0.30)(react-refresh@0.14.2): dependencies: '@babel/generator': 7.29.8 - '@babel/helper-module-imports': 7.29.7 - '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.7) - '@babel/plugin-proposal-export-default-from': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-syntax-export-default-from': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-flow-strip-types': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.7) - '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7) - '@babel/preset-react': 7.28.5(@babel/core@7.29.7) - '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7) - '@react-native/babel-preset': 0.83.10(@babel/core@7.29.7) + '@babel/helper-module-imports': 7.29.7(supports-color@8.1.1) + '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-proposal-export-default-from': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-export-default-from': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-flow-strip-types': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/preset-react': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) + '@react-native/babel-preset': 0.83.10(@babel/core@7.29.7(supports-color@8.1.1)) babel-plugin-react-compiler: 1.0.0 babel-plugin-react-native-web: 0.21.2 babel-plugin-syntax-hermes-parser: 0.32.1 - babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7) - debug: 4.4.3 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7(supports-color@8.1.1)) + debug: 4.4.3(supports-color@8.1.1) react-refresh: 0.14.2 resolve-from: 5.0.0 optionalDependencies: '@babel/runtime': 7.29.7 - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) transitivePeerDependencies: - '@babel/core' - supports-color - babel-preset-jest@29.6.3(@babel/core@7.29.7): + babel-preset-jest@29.6.3(@babel/core@7.29.7(supports-color@8.1.1)): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) babel-plugin-jest-hoist: 29.6.3 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7(supports-color@8.1.1)) badgin@1.2.3: {} @@ -11177,7 +11169,7 @@ snapshots: dependencies: bytes: 3.1.2 compressible: 2.0.18 - debug: 2.6.9 + debug: 2.6.9(supports-color@8.1.1) negotiator: 0.6.4 on-headers: 1.1.0 safe-buffer: 5.2.1 @@ -11187,10 +11179,10 @@ snapshots: concat-map@0.0.1: {} - connect@3.7.0: + connect@3.7.0(supports-color@8.1.1): dependencies: - debug: 2.6.9 - finalhandler: 1.1.2 + debug: 2.6.9(supports-color@8.1.1) + finalhandler: 1.1.2(supports-color@8.1.1) parseurl: 1.3.3 utils-merge: 1.0.1 transitivePeerDependencies: @@ -11210,13 +11202,13 @@ snapshots: dependencies: layout-base: 2.0.1 - create-jest@29.7.0(@types/node@26.4.0): + create-jest@29.7.0(@types/node@26.4.0)(supports-color@8.1.1): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@26.4.0) + jest-config: 29.7.0(@types/node@26.4.0)(supports-color@8.1.1) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -11476,17 +11468,21 @@ snapshots: dayjs@1.11.21: {} - debug@2.6.9: + debug@2.6.9(supports-color@8.1.1): dependencies: ms: 2.0.0 + optionalDependencies: + supports-color: 8.1.1 debug@3.2.7: dependencies: ms: 2.1.3 - debug@4.4.3: + debug@4.4.3(supports-color@8.1.1): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 decimal.js@10.6.0: {} @@ -11789,27 +11785,27 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-compat-utils@0.5.1(eslint@9.39.4): + eslint-compat-utils@0.5.1(eslint@9.39.4(supports-color@8.1.1)): dependencies: - eslint: 9.39.4 + eslint: 9.39.4(supports-color@8.1.1) semver: 7.8.5 - eslint-config-prettier@9.1.2(eslint@9.39.4): + eslint-config-prettier@9.1.2(eslint@9.39.4(supports-color@8.1.1)): dependencies: - eslint: 9.39.4 + eslint: 9.39.4(supports-color@8.1.1) - eslint-config-universe@15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3): + eslint-config-universe@15.0.4(eslint@9.39.4(supports-color@8.1.1))(prettier@2.8.8)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3) - eslint: 9.39.4 - eslint-config-prettier: 9.1.2(eslint@9.39.4) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4) - eslint-plugin-n: 17.24.0(eslint@9.39.4)(typescript@5.9.3) - eslint-plugin-node: 11.1.0(eslint@9.39.4) - eslint-plugin-prettier: 5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4))(eslint@9.39.4)(prettier@2.8.8) - eslint-plugin-react: 7.37.5(eslint@9.39.4) - eslint-plugin-react-hooks: 5.2.0(eslint@9.39.4) + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3))(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.2(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3) + eslint: 9.39.4(supports-color@8.1.1) + eslint-config-prettier: 9.1.2(eslint@9.39.4(supports-color@8.1.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3))(eslint@9.39.4(supports-color@8.1.1)) + eslint-plugin-n: 17.24.0(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3) + eslint-plugin-node: 11.1.0(eslint@9.39.4(supports-color@8.1.1)) + eslint-plugin-prettier: 5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4(supports-color@8.1.1)))(eslint@9.39.4(supports-color@8.1.1))(prettier@2.8.8) + eslint-plugin-react: 7.37.5(eslint@9.39.4(supports-color@8.1.1)) + eslint-plugin-react-hooks: 5.2.0(eslint@9.39.4(supports-color@8.1.1)) globals: 16.5.0 optionalDependencies: prettier: 2.8.8 @@ -11828,30 +11824,30 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4(supports-color@8.1.1)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3) - eslint: 9.39.4 + '@typescript-eslint/parser': 8.59.2(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3) + eslint: 9.39.4(supports-color@8.1.1) eslint-import-resolver-node: 0.3.10 transitivePeerDependencies: - supports-color - eslint-plugin-es-x@7.8.0(eslint@9.39.4): + eslint-plugin-es-x@7.8.0(eslint@9.39.4(supports-color@8.1.1)): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(supports-color@8.1.1)) '@eslint-community/regexpp': 4.12.2 - eslint: 9.39.4 - eslint-compat-utils: 0.5.1(eslint@9.39.4) + eslint: 9.39.4(supports-color@8.1.1) + eslint-compat-utils: 0.5.1(eslint@9.39.4(supports-color@8.1.1)) - eslint-plugin-es@3.0.1(eslint@9.39.4): + eslint-plugin-es@3.0.1(eslint@9.39.4(supports-color@8.1.1)): dependencies: - eslint: 9.39.4 + eslint: 9.39.4(supports-color@8.1.1) eslint-utils: 2.1.0 regexpp: 3.2.0 - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3))(eslint@9.39.4(supports-color@8.1.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -11860,9 +11856,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.39.4 + eslint: 9.39.4(supports-color@8.1.1) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4(supports-color@8.1.1)) hasown: 2.0.3 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -11874,18 +11870,18 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3) + '@typescript-eslint/parser': 8.59.2(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-n@17.24.0(eslint@9.39.4)(typescript@5.9.3): + eslint-plugin-n@17.24.0(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(supports-color@8.1.1)) enhanced-resolve: 5.21.0 - eslint: 9.39.4 - eslint-plugin-es-x: 7.8.0(eslint@9.39.4) + eslint: 9.39.4(supports-color@8.1.1) + eslint-plugin-es-x: 7.8.0(eslint@9.39.4(supports-color@8.1.1)) get-tsconfig: 4.14.0 globals: 15.15.0 globrex: 0.1.2 @@ -11895,30 +11891,30 @@ snapshots: transitivePeerDependencies: - typescript - eslint-plugin-node@11.1.0(eslint@9.39.4): + eslint-plugin-node@11.1.0(eslint@9.39.4(supports-color@8.1.1)): dependencies: - eslint: 9.39.4 - eslint-plugin-es: 3.0.1(eslint@9.39.4) + eslint: 9.39.4(supports-color@8.1.1) + eslint-plugin-es: 3.0.1(eslint@9.39.4(supports-color@8.1.1)) eslint-utils: 2.1.0 ignore: 5.3.2 minimatch: 3.1.5 resolve: 1.22.12 semver: 6.3.1 - eslint-plugin-prettier@5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4))(eslint@9.39.4)(prettier@2.8.8): + eslint-plugin-prettier@5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4(supports-color@8.1.1)))(eslint@9.39.4(supports-color@8.1.1))(prettier@2.8.8): dependencies: - eslint: 9.39.4 + eslint: 9.39.4(supports-color@8.1.1) prettier: 2.8.8 prettier-linter-helpers: 1.0.1 synckit: 0.11.12 optionalDependencies: - eslint-config-prettier: 9.1.2(eslint@9.39.4) + eslint-config-prettier: 9.1.2(eslint@9.39.4(supports-color@8.1.1)) - eslint-plugin-react-hooks@5.2.0(eslint@9.39.4): + eslint-plugin-react-hooks@5.2.0(eslint@9.39.4(supports-color@8.1.1)): dependencies: - eslint: 9.39.4 + eslint: 9.39.4(supports-color@8.1.1) - eslint-plugin-react@7.37.5(eslint@9.39.4): + eslint-plugin-react@7.37.5(eslint@9.39.4(supports-color@8.1.1)): dependencies: array-includes: 3.1.9 array.prototype.findlast: 1.2.5 @@ -11926,7 +11922,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.3.2 - eslint: 9.39.4 + eslint: 9.39.4(supports-color@8.1.1) estraverse: 5.3.0 hasown: 2.0.3 jsx-ast-utils: 3.3.5 @@ -11957,14 +11953,14 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.4: + eslint@9.39.4(supports-color@8.1.1): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.4) + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.4(supports-color@8.1.1)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2 + '@eslint/config-array': 0.21.2(supports-color@8.1.1) '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.6 + '@eslint/eslintrc': 3.3.6(supports-color@8.1.1) '@eslint/js': 9.39.4 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.8 @@ -11974,7 +11970,7 @@ snapshots: ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -12050,15 +12046,15 @@ snapshots: expo-application@55.0.19(expo@55.0.30): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) - expo-asset@55.0.20(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@6.0.3): + expo-asset@55.0.20(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@6.0.3): dependencies: '@expo/image-utils': 0.8.17(typescript@6.0.3) - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) - expo-constants: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) + expo-constants: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8)) react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) transitivePeerDependencies: - supports-color - typescript @@ -12066,42 +12062,42 @@ snapshots: expo-build-properties@55.0.18(expo@55.0.30): dependencies: '@expo/schema-utils': 55.0.5 - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) resolve-from: 5.0.0 semver: 7.8.5 - expo-camera@55.0.23(@types/emscripten@1.41.5)(expo@55.0.30)(react-native-web@0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + expo-camera@55.0.23(@types/emscripten@1.41.5)(expo@55.0.30)(react-native-web@0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: barcode-detector: 3.1.3(@types/emscripten@1.41.5) - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) optionalDependencies: react-native-web: 0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) transitivePeerDependencies: - '@types/emscripten' - expo-clipboard@55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + expo-clipboard@55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) - expo-constants@55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)): + expo-constants@55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8)): dependencies: '@expo/env': 2.1.3 - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) transitivePeerDependencies: - supports-color expo-crypto@55.0.19(expo@55.0.30): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) expo-dev-client@55.0.39(expo@55.0.30): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) expo-dev-launcher: 55.0.40(expo@55.0.30) expo-dev-menu: 55.0.34(expo@55.0.30) expo-dev-menu-interface: 55.0.2(expo@55.0.30) @@ -12111,64 +12107,64 @@ snapshots: expo-dev-launcher@55.0.40(expo@55.0.30): dependencies: '@expo/schema-utils': 55.0.5 - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) expo-dev-menu: 55.0.34(expo@55.0.30) expo-manifests: 55.0.21(expo@55.0.30) expo-dev-menu-interface@55.0.2(expo@55.0.30): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) expo-dev-menu@55.0.34(expo@55.0.30): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) expo-dev-menu-interface: 55.0.2(expo@55.0.30) expo-document-picker@55.0.17(expo@55.0.30): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) - expo-file-system@55.0.26(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)): + expo-file-system@55.0.26(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8)): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) - expo-font@55.0.8(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + expo-font@55.0.8(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) fontfaceobserver: 2.3.0 react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) - expo-glass-effect@55.0.11(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + expo-glass-effect@55.0.11(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) expo-haptics@55.0.18(expo@55.0.30): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) expo-image-loader@55.0.1(expo@55.0.30): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) expo-image-manipulator@55.0.21(expo@55.0.30): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) expo-image-loader: 55.0.1(expo@55.0.30) expo-image-picker@55.0.24(expo@55.0.30): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) expo-image-loader: 55.0.1(expo@55.0.30) - expo-image@55.0.11(expo@55.0.30)(react-native-web@0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + expo-image@55.0.11(expo@55.0.30)(react-native-web@0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) sf-symbols-typescript: 2.2.0 optionalDependencies: react-native-web: 0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -12177,45 +12173,45 @@ snapshots: expo-keep-awake@55.0.8(expo@55.0.30)(react@19.2.8): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) react: 19.2.8 - expo-linking@55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + expo-linking@55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: - expo-constants: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)) + expo-constants: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8)) invariant: 2.2.4 react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) transitivePeerDependencies: - expo - supports-color expo-manifests@55.0.21(expo@55.0.30): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) expo-json-utils: 55.0.2 - expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8): + expo-module-scripts@55.0.2(@babel/core@7.29.7(supports-color@8.1.1))(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(esbuild@0.25.4)(eslint@9.39.4(supports-color@8.1.1))(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0)(supports-color@8.1.1))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@babel/cli': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7) - '@babel/preset-env': 7.29.5(@babel/core@7.29.7) - '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7) + '@babel/cli': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/preset-env': 7.29.5(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) '@expo/npm-proofread': 1.0.1 '@expo/spawn-async': 1.7.2 - '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.4.0)(supports-color@8.1.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) '@tsconfig/node18': 18.2.6 '@types/jest': 29.5.14 babel-plugin-dynamic-import-node: 2.3.3 - babel-preset-expo: 55.0.21(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@55.0.30)(react-refresh@0.14.2) + babel-preset-expo: 55.0.21(@babel/core@7.29.7(supports-color@8.1.1))(@babel/runtime@7.29.7)(expo@55.0.30)(react-refresh@0.14.2) commander: 12.1.0 - eslint-config-universe: 15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3) + eslint-config-universe: 15.0.4(eslint@9.39.4(supports-color@8.1.1))(prettier@2.8.8)(typescript@5.9.3) glob: 13.0.6 - jest-expo: 55.0.17(@babel/core@7.29.7)(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3) + jest-expo: 55.0.17(@babel/core@7.29.7(supports-color@8.1.1))(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0)(supports-color@8.1.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3) jest-snapshot-prettier: prettier@2.8.8 - jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.4.0)) + jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.4.0)(supports-color@8.1.1)) resolve-workspace-root: 2.0.1 - ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.4.0))(typescript@5.9.3) + ts-jest: 29.0.5(@babel/core@7.29.7(supports-color@8.1.1))(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.4.0)(supports-color@8.1.1))(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - '@babel/core' @@ -12251,63 +12247,63 @@ snapshots: - supports-color - typescript - expo-modules-core@55.0.25(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + expo-modules-core@55.0.25(react-native-worklets@0.8.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: invariant: 2.2.4 react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) optionalDependencies: - react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + react-native-worklets: 0.8.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) expo-network@55.0.18(expo@55.0.30)(react@19.2.8): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) react: 19.2.8 - expo-notifications@55.0.27(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@6.0.3): + expo-notifications@55.0.27(patch_hash=ce20843a3daad4185d7e8571788fa323ba4d11984936188790858650a61749c0)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@6.0.3): dependencies: '@expo/image-utils': 0.8.17(typescript@6.0.3) abort-controller: 3.0.0 badgin: 1.2.3 - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) expo-application: 55.0.19(expo@55.0.30) - expo-constants: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)) + expo-constants: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8)) react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) transitivePeerDependencies: - supports-color - typescript - expo-router@55.0.18(4a60a26fd685ffdcc7f556016ae4bc5e): + expo-router@55.0.18(98b45897562456c6c413f91e81d6c336): dependencies: - '@expo/log-box': 55.0.13(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@expo/log-box': 55.0.13(@expo/dom-webview@55.0.6)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@expo/metro-runtime': 55.0.12(@expo/dom-webview@55.0.6)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) '@expo/schema-utils': 55.0.5 '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.8) '@radix-ui/react-tabs': 1.1.13(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@react-navigation/bottom-tabs': 7.15.11(@react-navigation/native@7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native-safe-area-context@5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native-screens@4.24.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - '@react-navigation/native': 7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - '@react-navigation/native-stack': 7.14.12(@react-navigation/native@7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native-safe-area-context@5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native-screens@4.24.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@react-navigation/bottom-tabs': 7.15.11(edc9e9b19f67aea8a6d8a2ee54babcc5) + '@react-navigation/native': 7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@react-navigation/native-stack': 7.14.12(edc9e9b19f67aea8a6d8a2ee54babcc5) client-only: 0.0.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) escape-string-regexp: 4.0.0 - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) - expo-constants: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)) - expo-glass-effect: 55.0.11(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - expo-image: 55.0.11(expo@55.0.30)(react-native-web@0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - expo-linking: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) + expo-constants: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8)) + expo-glass-effect: 55.0.11(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + expo-image: 55.0.11(expo@55.0.30)(react-native-web@0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + expo-linking: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) expo-server: 55.0.12 - expo-symbols: 55.0.9(expo-font@55.0.8)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + expo-symbols: 55.0.9(expo-font@55.0.8)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) fast-deep-equal: 3.1.3 invariant: 2.2.4 nanoid: 3.3.18 query-string: 7.1.3 react: 19.2.8 react-fast-compare: 3.2.2 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) - react-native-is-edge-to-edge: 1.3.1(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - react-native-safe-area-context: 5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - react-native-screens: 4.24.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) + react-native-is-edge-to-edge: 1.3.1(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + react-native-safe-area-context: 5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + react-native-screens: 4.24.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) semver: 7.6.3 server-only: 0.0.1 sf-symbols-typescript: 2.2.0 @@ -12315,10 +12311,10 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.8) vaul: 1.1.2(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) optionalDependencies: - '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.4.0)(supports-color@8.1.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) react-dom: 19.2.8(react@19.2.8) - react-native-gesture-handler: 2.31.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - react-native-reanimated: 4.3.4(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + react-native-gesture-handler: 2.31.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + react-native-reanimated: 4.3.4(react-native-worklets@0.8.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) react-native-web: 0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) transitivePeerDependencies: - '@react-native-masked-view/masked-view' @@ -12329,68 +12325,74 @@ snapshots: expo-secure-store@55.0.18(expo@55.0.30): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) expo-server@55.0.12: {} expo-splash-screen@55.0.25(expo@55.0.30)(typescript@6.0.3): dependencies: '@expo/prebuild-config': 55.0.22(expo@55.0.30)(typescript@6.0.3) - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) transitivePeerDependencies: - supports-color - typescript - expo-status-bar@55.0.6(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + expo-status-bar@55.0.6(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) - react-native-is-edge-to-edge: 1.3.1(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) + react-native-is-edge-to-edge: 1.3.1(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - expo-symbols@55.0.9(expo-font@55.0.8)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + expo-symbols@55.0.9(expo-font@55.0.8)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: '@expo-google-fonts/material-symbols': 0.4.34 - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) - expo-font: 55.0.8(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) + expo-font: 55.0.8(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) sf-symbols-typescript: 2.2.0 + expo-task-manager@55.0.20(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8)): + dependencies: + expo: 55.0.30(09911ea01feb2f63557d787d92391924) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) + unimodules-app-loader: 55.0.5 + expo-updates-interface@55.1.6(expo@55.0.30): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) - expo@55.0.30(10e8e71dd92768dd7f344108f3edbbe3): + expo@55.0.30(09911ea01feb2f63557d787d92391924): dependencies: '@babel/runtime': 7.29.7 - '@expo/cli': 55.0.36(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-constants@55.0.17)(expo-font@55.0.8)(expo-router@55.0.18)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@6.0.3) + '@expo/cli': 55.0.36(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.12)(expo-constants@55.0.17)(expo-font@55.0.8)(expo-router@55.0.18)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@6.0.3) '@expo/config': 55.0.21(typescript@6.0.3) '@expo/config-plugins': 55.0.11 - '@expo/devtools': 55.0.3(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@expo/devtools': 55.0.3(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) '@expo/fingerprint': 0.16.8 '@expo/local-build-cache-provider': 55.0.16(typescript@6.0.3) - '@expo/log-box': 55.0.13(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@expo/log-box': 55.0.13(@expo/dom-webview@55.0.6)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) '@expo/metro': 55.1.2 '@expo/metro-config': 55.0.27(expo@55.0.30)(typescript@6.0.3) - '@expo/vector-icons': 15.1.1(expo-font@55.0.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@expo/vector-icons': 15.1.1(expo-font@55.0.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) '@ungap/structured-clone': 1.3.1 - babel-preset-expo: 55.0.25(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@55.0.30)(react-refresh@0.14.2) - expo-asset: 55.0.20(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@6.0.3) - expo-constants: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)) - expo-file-system: 55.0.26(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)) - expo-font: 55.0.8(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + babel-preset-expo: 55.0.25(@babel/core@7.29.7(supports-color@8.1.1))(@babel/runtime@7.29.7)(expo@55.0.30)(react-refresh@0.14.2) + expo-asset: 55.0.20(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@6.0.3) + expo-constants: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8)) + expo-file-system: 55.0.26(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8)) + expo-font: 55.0.8(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) expo-keep-awake: 55.0.8(expo@55.0.30)(react@19.2.8) expo-modules-autolinking: 55.0.27(typescript@6.0.3) - expo-modules-core: 55.0.25(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + expo-modules-core: 55.0.25(react-native-worklets@0.8.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) pretty-format: 29.7.0 react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) react-refresh: 0.14.2 whatwg-url-minimum: 0.1.2 optionalDependencies: - '@expo/dom-webview': 55.0.5(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - react-native-webview: 13.16.2(patch_hash=de6761dfa76a5491a23e49f1262566a8831cab26736a336fdffc5d4e7d05ff27)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@expo/dom-webview': 55.0.6(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@expo/metro-runtime': 55.0.12(@expo/dom-webview@55.0.6)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + react-native-webview: 13.16.2(patch_hash=de6761dfa76a5491a23e49f1262566a8831cab26736a336fdffc5d4e7d05ff27)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) transitivePeerDependencies: - '@babel/core' - bufferutil @@ -12453,9 +12455,9 @@ snapshots: filter-obj@1.1.0: {} - finalhandler@1.1.2: + finalhandler@1.1.2(supports-color@8.1.1): dependencies: - debug: 2.6.9 + debug: 2.6.9(supports-color@8.1.1) encodeurl: 1.0.2 escape-html: 1.0.3 on-finished: 2.3.0 @@ -12696,25 +12698,25 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - http-proxy-agent@5.0.0: + http-proxy-agent@5.0.0(supports-color@8.1.1): dependencies: '@tootallnate/once': 2.0.1 - agent-base: 6.0.2 - debug: 4.4.3 + agent-base: 6.0.2(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color - https-proxy-agent@5.0.1: + https-proxy-agent@5.0.1(supports-color@8.1.1): dependencies: - agent-base: 6.0.2 - debug: 4.4.3 + agent-base: 6.0.2(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color - https-proxy-agent@7.0.6: + https-proxy-agent@7.0.6(supports-color@8.1.1): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -12916,9 +12918,9 @@ snapshots: istanbul-lib-coverage@3.2.2: {} - istanbul-lib-instrument@5.2.1: + istanbul-lib-instrument@5.2.1(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/parser': 7.29.7 '@istanbuljs/schema': 0.1.6 istanbul-lib-coverage: 3.2.2 @@ -12926,9 +12928,9 @@ snapshots: transitivePeerDependencies: - supports-color - istanbul-lib-instrument@6.0.3: + istanbul-lib-instrument@6.0.3(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/parser': 7.29.8 '@istanbuljs/schema': 0.1.6 istanbul-lib-coverage: 3.2.2 @@ -12942,9 +12944,9 @@ snapshots: make-dir: 4.0.0 supports-color: 7.2.0 - istanbul-lib-source-maps@4.0.1: + istanbul-lib-source-maps@4.0.1(supports-color@8.1.1): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -12970,10 +12972,10 @@ snapshots: jest-util: 29.7.0 p-limit: 3.1.0 - jest-circus@29.7.0: + jest-circus@29.7.0(supports-color@8.1.1): dependencies: '@jest/environment': 29.7.0 - '@jest/expect': 29.7.0 + '@jest/expect': 29.7.0(supports-color@8.1.1) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 '@types/node': 26.4.0 @@ -12984,8 +12986,8 @@ snapshots: jest-each: 29.7.0 jest-matcher-utils: 29.7.0 jest-message-util: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 + jest-runtime: 29.7.0(supports-color@8.1.1) + jest-snapshot: 29.7.0(supports-color@8.1.1) jest-util: 29.7.0 p-limit: 3.1.0 pretty-format: 29.7.0 @@ -12996,16 +12998,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@26.4.0): + jest-cli@29.7.0(@types/node@26.4.0)(supports-color@8.1.1): dependencies: - '@jest/core': 29.7.0 + '@jest/core': 29.7.0(supports-color@8.1.1) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@26.4.0) + create-jest: 29.7.0(@types/node@26.4.0)(supports-color@8.1.1) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@26.4.0) + jest-config: 29.7.0(@types/node@26.4.0)(supports-color@8.1.1) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.3 @@ -13015,23 +13017,23 @@ snapshots: - supports-color - ts-node - jest-config@29.7.0(@types/node@26.4.0): + jest-config@29.7.0(@types/node@26.4.0)(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@jest/test-sequencer': 29.7.0 '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.29.7) + babel-jest: 29.7.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) chalk: 4.1.2 ci-info: 3.9.0 deepmerge: 4.3.1 glob: 7.2.3 graceful-fs: 4.2.11 - jest-circus: 29.7.0 + jest-circus: 29.7.0(supports-color@8.1.1) jest-environment-node: 29.7.0 jest-get-type: 29.6.3 jest-regex-util: 29.6.3 jest-resolve: 29.7.0 - jest-runner: 29.7.0 + jest-runner: 29.7.0(supports-color@8.1.1) jest-util: 29.7.0 jest-validate: 29.7.0 micromatch: 4.0.8 @@ -13080,7 +13082,7 @@ snapshots: '@types/node': 25.6.0 jest-mock: 29.7.0 jest-util: 29.7.0 - jsdom: 20.0.3 + jsdom: 20.0.3(supports-color@8.1.1) transitivePeerDependencies: - bufferutil - supports-color @@ -13095,21 +13097,21 @@ snapshots: jest-mock: 29.7.0 jest-util: 29.7.0 - jest-expo@55.0.17(@babel/core@7.29.7)(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3): + jest-expo@55.0.17(@babel/core@7.29.7(supports-color@8.1.1))(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0)(supports-color@8.1.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3): dependencies: '@expo/config': 55.0.16(typescript@5.9.3) '@expo/json-file': 10.0.14 '@jest/create-cache-key-function': 29.7.0 - '@jest/globals': 29.7.0 - babel-jest: 29.7.0(@babel/core@7.29.7) - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + '@jest/globals': 29.7.0(supports-color@8.1.1) + babel-jest: 29.7.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) jest-environment-jsdom: 29.7.0 - jest-snapshot: 29.7.0 + jest-snapshot: 29.7.0(supports-color@8.1.1) jest-watch-select-projects: 2.0.0 - jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.4.0)) + jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.4.0)(supports-color@8.1.1)) json5: 2.2.3 lodash: 4.18.1 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) react-test-renderer: 19.2.0(react@19.2.8) server-only: 0.0.1 stacktrace-js: 2.0.2 @@ -13184,10 +13186,10 @@ snapshots: jest-regex-util@29.6.3: {} - jest-resolve-dependencies@29.7.0: + jest-resolve-dependencies@29.7.0(supports-color@8.1.1): dependencies: jest-regex-util: 29.6.3 - jest-snapshot: 29.7.0 + jest-snapshot: 29.7.0(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -13203,12 +13205,12 @@ snapshots: resolve.exports: 2.0.3 slash: 3.0.0 - jest-runner@29.7.0: + jest-runner@29.7.0(supports-color@8.1.1): dependencies: '@jest/console': 29.7.0 '@jest/environment': 29.7.0 '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 + '@jest/transform': 29.7.0(supports-color@8.1.1) '@jest/types': 29.6.3 '@types/node': 26.4.0 chalk: 4.1.2 @@ -13220,7 +13222,7 @@ snapshots: jest-leak-detector: 29.7.0 jest-message-util: 29.7.0 jest-resolve: 29.7.0 - jest-runtime: 29.7.0 + jest-runtime: 29.7.0(supports-color@8.1.1) jest-util: 29.7.0 jest-watcher: 29.7.0 jest-worker: 29.7.0 @@ -13229,14 +13231,14 @@ snapshots: transitivePeerDependencies: - supports-color - jest-runtime@29.7.0: + jest-runtime@29.7.0(supports-color@8.1.1): dependencies: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 - '@jest/globals': 29.7.0 + '@jest/globals': 29.7.0(supports-color@8.1.1) '@jest/source-map': 29.6.3 '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 + '@jest/transform': 29.7.0(supports-color@8.1.1) '@jest/types': 29.6.3 '@types/node': 26.4.0 chalk: 4.1.2 @@ -13249,24 +13251,24 @@ snapshots: jest-mock: 29.7.0 jest-regex-util: 29.6.3 jest-resolve: 29.7.0 - jest-snapshot: 29.7.0 + jest-snapshot: 29.7.0(supports-color@8.1.1) jest-util: 29.7.0 slash: 3.0.0 strip-bom: 4.0.0 transitivePeerDependencies: - supports-color - jest-snapshot@29.7.0: + jest-snapshot@29.7.0(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/generator': 7.29.1 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/types': 7.29.0 '@jest/expect-utils': 29.7.0 - '@jest/transform': 29.7.0 + '@jest/transform': 29.7.0(supports-color@8.1.1) '@jest/types': 29.6.3 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7(supports-color@8.1.1)) chalk: 4.1.2 expect: 29.7.0 graceful-fs: 4.2.11 @@ -13305,11 +13307,11 @@ snapshots: chalk: 3.0.0 prompts: 2.4.2 - jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@26.4.0)): + jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@26.4.0)(supports-color@8.1.1)): dependencies: ansi-escapes: 6.2.1 chalk: 4.1.2 - jest: 29.7.0(@types/node@26.4.0) + jest: 29.7.0(@types/node@26.4.0)(supports-color@8.1.1) jest-regex-util: 29.6.3 jest-watcher: 29.7.0 slash: 5.1.0 @@ -13334,12 +13336,12 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@26.4.0): + jest@29.7.0(@types/node@26.4.0)(supports-color@8.1.1): dependencies: - '@jest/core': 29.7.0 + '@jest/core': 29.7.0(supports-color@8.1.1) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@26.4.0) + jest-cli: 29.7.0(@types/node@26.4.0)(supports-color@8.1.1) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -13365,7 +13367,7 @@ snapshots: jsc-safe-url@0.2.4: {} - jsdom@20.0.3: + jsdom@20.0.3(supports-color@8.1.1): dependencies: abab: 2.0.6 acorn: 8.15.0 @@ -13378,8 +13380,8 @@ snapshots: escodegen: 2.1.0 form-data: 4.0.6 html-encoding-sniffer: 3.0.0 - http-proxy-agent: 5.0.0 - https-proxy-agent: 5.0.1 + http-proxy-agent: 5.0.0(supports-color@8.1.1) + https-proxy-agent: 5.0.1(supports-color@8.1.1) is-potential-custom-element-name: 1.0.1 nwsapi: 2.2.23 parse5: 7.3.0 @@ -13448,7 +13450,7 @@ snapshots: lighthouse-logger@1.4.2: dependencies: - debug: 2.6.9 + debug: 2.6.9(supports-color@8.1.1) marky: 1.3.0 transitivePeerDependencies: - supports-color @@ -13546,11 +13548,11 @@ snapshots: dependencies: yallist: 3.1.1 - lucide-react-native@1.14.0(react-native-svg@15.15.4(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + lucide-react-native@1.14.0(react-native-svg@15.15.4(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) - react-native-svg: 15.15.4(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) + react-native-svg: 15.15.4(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) magic-string@0.30.21: dependencies: @@ -13614,9 +13616,9 @@ snapshots: ts-dedent: 2.3.0 uuid: 11.1.1 - metro-babel-transformer@0.83.7: + metro-babel-transformer@0.83.7(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) flow-enums-runtime: 0.0.6 hermes-parser: 0.35.0 metro-cache-key: 0.83.7 @@ -13624,9 +13626,9 @@ snapshots: transitivePeerDependencies: - supports-color - metro-babel-transformer@0.83.8: + metro-babel-transformer@0.83.8(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) flow-enums-runtime: 0.0.6 hermes-parser: 0.35.0 metro-cache-key: 0.83.8 @@ -13634,9 +13636,9 @@ snapshots: transitivePeerDependencies: - supports-color - metro-babel-transformer@0.84.5: + metro-babel-transformer@0.84.5(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) flow-enums-runtime: 0.0.6 hermes-parser: 0.35.0 metro-cache-key: 0.84.5 @@ -13656,40 +13658,40 @@ snapshots: dependencies: flow-enums-runtime: 0.0.6 - metro-cache@0.83.7: + metro-cache@0.83.7(supports-color@8.1.1): dependencies: exponential-backoff: 3.1.3 flow-enums-runtime: 0.0.6 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@8.1.1) metro-core: 0.83.7 transitivePeerDependencies: - supports-color - metro-cache@0.83.8: + metro-cache@0.83.8(supports-color@8.1.1): dependencies: exponential-backoff: 3.1.3 flow-enums-runtime: 0.0.6 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@8.1.1) metro-core: 0.83.8 transitivePeerDependencies: - supports-color - metro-cache@0.84.5: + metro-cache@0.84.5(supports-color@8.1.1): dependencies: exponential-backoff: 3.1.3 flow-enums-runtime: 0.0.6 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@8.1.1) metro-core: 0.84.5 transitivePeerDependencies: - supports-color - metro-config@0.83.7: + metro-config@0.83.7(supports-color@8.1.1): dependencies: - connect: 3.7.0 + connect: 3.7.0(supports-color@8.1.1) flow-enums-runtime: 0.0.6 jest-validate: 29.7.0 - metro: 0.83.7 - metro-cache: 0.83.7 + metro: 0.83.7(supports-color@8.1.1) + metro-cache: 0.83.7(supports-color@8.1.1) metro-core: 0.83.7 metro-runtime: 0.83.7 yaml: 2.9.0 @@ -13698,13 +13700,13 @@ snapshots: - supports-color - utf-8-validate - metro-config@0.83.8: + metro-config@0.83.8(supports-color@8.1.1): dependencies: - connect: 3.7.0 + connect: 3.7.0(supports-color@8.1.1) flow-enums-runtime: 0.0.6 jest-validate: 29.7.0 - metro: 0.83.8 - metro-cache: 0.83.8 + metro: 0.83.8(supports-color@8.1.1) + metro-cache: 0.83.8(supports-color@8.1.1) metro-core: 0.83.8 metro-runtime: 0.83.8 yaml: 2.9.0 @@ -13713,13 +13715,13 @@ snapshots: - supports-color - utf-8-validate - metro-config@0.84.5: + metro-config@0.84.5(supports-color@8.1.1): dependencies: - connect: 3.7.0 + connect: 3.7.0(supports-color@8.1.1) flow-enums-runtime: 0.0.6 jest-validate: 29.7.0 - metro: 0.84.5 - metro-cache: 0.84.5 + metro: 0.84.5(supports-color@8.1.1) + metro-cache: 0.84.5(supports-color@8.1.1) metro-core: 0.84.5 metro-runtime: 0.84.5 yaml: 2.9.0 @@ -13746,9 +13748,9 @@ snapshots: lodash.throttle: 4.1.1 metro-resolver: 0.84.5 - metro-file-map@0.83.7: + metro-file-map@0.83.7(supports-color@8.1.1): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) fb-watchman: 2.0.2 flow-enums-runtime: 0.0.6 graceful-fs: 4.2.11 @@ -13760,9 +13762,9 @@ snapshots: transitivePeerDependencies: - supports-color - metro-file-map@0.83.8: + metro-file-map@0.83.8(supports-color@8.1.1): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) fb-watchman: 2.0.2 flow-enums-runtime: 0.0.6 graceful-fs: 4.2.11 @@ -13774,9 +13776,9 @@ snapshots: transitivePeerDependencies: - supports-color - metro-file-map@0.84.5: + metro-file-map@0.84.5(supports-color@8.1.1): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) fb-watchman: 2.0.2 flow-enums-runtime: 0.0.6 graceful-fs: 4.2.11 @@ -13830,9 +13832,9 @@ snapshots: '@babel/runtime': 7.29.7 flow-enums-runtime: 0.0.6 - metro-source-map@0.83.7: + metro-source-map@0.83.7(supports-color@8.1.1): dependencies: - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@8.1.1) '@babel/types': 7.29.8 flow-enums-runtime: 0.0.6 invariant: 2.2.4 @@ -13844,9 +13846,9 @@ snapshots: transitivePeerDependencies: - supports-color - metro-source-map@0.83.8: + metro-source-map@0.83.8(supports-color@8.1.1): dependencies: - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@8.1.1) '@babel/types': 7.29.8 flow-enums-runtime: 0.0.6 invariant: 2.2.4 @@ -13858,9 +13860,9 @@ snapshots: transitivePeerDependencies: - supports-color - metro-source-map@0.84.5: + metro-source-map@0.84.5(supports-color@8.1.1): dependencies: - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@8.1.1) '@babel/types': 7.29.8 flow-enums-runtime: 0.0.6 invariant: 2.2.4 @@ -13876,141 +13878,135 @@ snapshots: dependencies: flow-enums-runtime: 0.0.6 invariant: 2.2.4 - metro-source-map: 0.83.7 + metro-source-map: 0.83.7(supports-color@8.1.1) nullthrows: 1.1.1 source-map: 0.5.7 vlq: 1.0.1 - transitivePeerDependencies: - - supports-color metro-symbolicate@0.83.8: dependencies: flow-enums-runtime: 0.0.6 invariant: 2.2.4 - metro-source-map: 0.83.8 + metro-source-map: 0.83.8(supports-color@8.1.1) nullthrows: 1.1.1 source-map: 0.5.7 vlq: 1.0.1 - transitivePeerDependencies: - - supports-color metro-symbolicate@0.84.5: dependencies: flow-enums-runtime: 0.0.6 invariant: 2.2.4 - metro-source-map: 0.84.5 + metro-source-map: 0.84.5(supports-color@8.1.1) nullthrows: 1.1.1 source-map: 0.5.7 vlq: 1.0.1 - transitivePeerDependencies: - - supports-color - metro-transform-plugins@0.83.7: + metro-transform-plugins@0.83.7(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/generator': 7.29.8 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@8.1.1) flow-enums-runtime: 0.0.6 nullthrows: 1.1.1 transitivePeerDependencies: - supports-color - metro-transform-plugins@0.83.8: + metro-transform-plugins@0.83.8(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/generator': 7.29.8 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@8.1.1) flow-enums-runtime: 0.0.6 nullthrows: 1.1.1 transitivePeerDependencies: - supports-color - metro-transform-plugins@0.84.5: + metro-transform-plugins@0.84.5(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/generator': 7.29.8 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@8.1.1) flow-enums-runtime: 0.0.6 nullthrows: 1.1.1 transitivePeerDependencies: - supports-color - metro-transform-worker@0.83.7: + metro-transform-worker@0.83.7(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/generator': 7.29.8 '@babel/parser': 7.29.8 '@babel/types': 7.29.8 flow-enums-runtime: 0.0.6 - metro: 0.83.7 - metro-babel-transformer: 0.83.7 - metro-cache: 0.83.7 + metro: 0.83.7(supports-color@8.1.1) + metro-babel-transformer: 0.83.7(supports-color@8.1.1) + metro-cache: 0.83.7(supports-color@8.1.1) metro-cache-key: 0.83.7 metro-minify-terser: 0.83.7 - metro-source-map: 0.83.7 - metro-transform-plugins: 0.83.7 + metro-source-map: 0.83.7(supports-color@8.1.1) + metro-transform-plugins: 0.83.7(supports-color@8.1.1) nullthrows: 1.1.1 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - metro-transform-worker@0.83.8: + metro-transform-worker@0.83.8(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/generator': 7.29.8 '@babel/parser': 7.29.8 '@babel/types': 7.29.8 flow-enums-runtime: 0.0.6 - metro: 0.83.8 - metro-babel-transformer: 0.83.8 - metro-cache: 0.83.8 + metro: 0.83.8(supports-color@8.1.1) + metro-babel-transformer: 0.83.8(supports-color@8.1.1) + metro-cache: 0.83.8(supports-color@8.1.1) metro-cache-key: 0.83.8 metro-minify-terser: 0.83.8 - metro-source-map: 0.83.8 - metro-transform-plugins: 0.83.8 + metro-source-map: 0.83.8(supports-color@8.1.1) + metro-transform-plugins: 0.83.8(supports-color@8.1.1) nullthrows: 1.1.1 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - metro-transform-worker@0.84.5: + metro-transform-worker@0.84.5(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/generator': 7.29.8 '@babel/parser': 7.29.8 '@babel/types': 7.29.8 flow-enums-runtime: 0.0.6 - metro: 0.84.5 - metro-babel-transformer: 0.84.5 - metro-cache: 0.84.5 + metro: 0.84.5(supports-color@8.1.1) + metro-babel-transformer: 0.84.5(supports-color@8.1.1) + metro-cache: 0.84.5(supports-color@8.1.1) metro-cache-key: 0.84.5 metro-minify-terser: 0.84.5 - metro-source-map: 0.84.5 - metro-transform-plugins: 0.84.5 + metro-source-map: 0.84.5(supports-color@8.1.1) + metro-transform-plugins: 0.84.5(supports-color@8.1.1) nullthrows: 1.1.1 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - metro@0.83.7: + metro@0.83.7(supports-color@8.1.1): dependencies: '@babel/code-frame': 7.29.7 - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/generator': 7.29.8 '@babel/parser': 7.29.8 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@8.1.1) '@babel/types': 7.29.8 accepts: 2.0.0 ci-info: 2.0.0 - connect: 3.7.0 - debug: 4.4.3 + connect: 3.7.0(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) error-stack-parser: 2.1.4 flow-enums-runtime: 0.0.6 graceful-fs: 4.2.11 @@ -14020,18 +14016,18 @@ snapshots: jest-worker: 29.7.0 jsc-safe-url: 0.2.4 lodash.throttle: 4.1.1 - metro-babel-transformer: 0.83.7 - metro-cache: 0.83.7 + metro-babel-transformer: 0.83.7(supports-color@8.1.1) + metro-cache: 0.83.7(supports-color@8.1.1) metro-cache-key: 0.83.7 - metro-config: 0.83.7 + metro-config: 0.83.7(supports-color@8.1.1) metro-core: 0.83.7 - metro-file-map: 0.83.7 + metro-file-map: 0.83.7(supports-color@8.1.1) metro-resolver: 0.83.7 metro-runtime: 0.83.7 - metro-source-map: 0.83.7 + metro-source-map: 0.83.7(supports-color@8.1.1) metro-symbolicate: 0.83.7 - metro-transform-plugins: 0.83.7 - metro-transform-worker: 0.83.7 + metro-transform-plugins: 0.83.7(supports-color@8.1.1) + metro-transform-worker: 0.83.7(supports-color@8.1.1) mime-types: 3.0.2 nullthrows: 1.1.1 serialize-error: 2.1.0 @@ -14044,19 +14040,19 @@ snapshots: - supports-color - utf-8-validate - metro@0.83.8: + metro@0.83.8(supports-color@8.1.1): dependencies: '@babel/code-frame': 7.29.7 - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/generator': 7.29.8 '@babel/parser': 7.29.8 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@8.1.1) '@babel/types': 7.29.8 accepts: 2.0.0 ci-info: 2.0.0 - connect: 3.7.0 - debug: 4.4.3 + connect: 3.7.0(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) error-stack-parser: 2.1.4 flow-enums-runtime: 0.0.6 graceful-fs: 4.2.11 @@ -14065,18 +14061,18 @@ snapshots: jest-worker: 29.7.0 jsc-safe-url: 0.2.4 lodash.throttle: 4.1.1 - metro-babel-transformer: 0.83.8 - metro-cache: 0.83.8 + metro-babel-transformer: 0.83.8(supports-color@8.1.1) + metro-cache: 0.83.8(supports-color@8.1.1) metro-cache-key: 0.83.8 - metro-config: 0.83.8 + metro-config: 0.83.8(supports-color@8.1.1) metro-core: 0.83.8 - metro-file-map: 0.83.8 + metro-file-map: 0.83.8(supports-color@8.1.1) metro-resolver: 0.83.8 metro-runtime: 0.83.8 - metro-source-map: 0.83.8 + metro-source-map: 0.83.8(supports-color@8.1.1) metro-symbolicate: 0.83.8 - metro-transform-plugins: 0.83.8 - metro-transform-worker: 0.83.8 + metro-transform-plugins: 0.83.8(supports-color@8.1.1) + metro-transform-worker: 0.83.8(supports-color@8.1.1) mime-types: 3.0.2 nullthrows: 1.1.1 serialize-error: 2.1.0 @@ -14089,19 +14085,19 @@ snapshots: - supports-color - utf-8-validate - metro@0.84.5: + metro@0.84.5(supports-color@8.1.1): dependencies: '@babel/code-frame': 7.29.7 - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/generator': 7.29.8 '@babel/parser': 7.29.8 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@8.1.1) '@babel/types': 7.29.8 accepts: 2.0.0 ci-info: 2.0.0 - connect: 3.7.0 - debug: 4.4.3 + connect: 3.7.0(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) error-stack-parser: 2.1.4 flow-enums-runtime: 0.0.6 graceful-fs: 4.2.11 @@ -14110,18 +14106,18 @@ snapshots: jest-worker: 29.7.0 jsc-safe-url: 0.2.4 lodash.throttle: 4.1.1 - metro-babel-transformer: 0.84.5 - metro-cache: 0.84.5 + metro-babel-transformer: 0.84.5(supports-color@8.1.1) + metro-cache: 0.84.5(supports-color@8.1.1) metro-cache-key: 0.84.5 - metro-config: 0.84.5 + metro-config: 0.84.5(supports-color@8.1.1) metro-core: 0.84.5 - metro-file-map: 0.84.5 + metro-file-map: 0.84.5(supports-color@8.1.1) metro-resolver: 0.84.5 metro-runtime: 0.84.5 - metro-source-map: 0.84.5 + metro-source-map: 0.84.5(supports-color@8.1.1) metro-symbolicate: 0.84.5 - metro-transform-plugins: 0.84.5 - metro-transform-worker: 0.84.5 + metro-transform-plugins: 0.84.5(supports-color@8.1.1) + metro-transform-worker: 0.84.5(supports-color@8.1.1) mime-types: 3.0.2 nullthrows: 1.1.1 serialize-error: 2.1.0 @@ -14576,52 +14572,52 @@ snapshots: react-is@19.2.8: {} - react-native-gesture-handler@2.31.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + react-native-gesture-handler@2.31.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: '@egjs/hammerjs': 2.0.17 '@types/react-test-renderer': 19.1.0 hoist-non-react-statics: 3.3.2 invariant: 2.2.4 react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) - react-native-is-edge-to-edge@1.3.1(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + react-native-is-edge-to-edge@1.3.1(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) - react-native-reanimated@4.3.4(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + react-native-reanimated@4.3.4(react-native-worklets@0.8.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) - react-native-is-edge-to-edge: 1.3.1(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) + react-native-is-edge-to-edge: 1.3.1(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + react-native-worklets: 0.8.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) semver: 7.8.5 - react-native-safe-area-context@5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + react-native-safe-area-context@5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) - react-native-screens@4.24.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + react-native-screens@4.24.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: react: 19.2.8 react-freeze: 1.0.4(react@19.2.8) - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) warn-once: 0.1.1 - react-native-svg@15.15.4(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + react-native-svg@15.15.4(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: css-select: 5.2.2 css-tree: 1.1.3 react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) warn-once: 0.1.1 - react-native-uitextview@2.2.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + react-native-uitextview@2.2.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) react-native-web@0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: @@ -14638,48 +14634,48 @@ snapshots: transitivePeerDependencies: - encoding - react-native-webview@13.16.2(patch_hash=de6761dfa76a5491a23e49f1262566a8831cab26736a336fdffc5d4e7d05ff27)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + react-native-webview@13.16.2(patch_hash=de6761dfa76a5491a23e49f1262566a8831cab26736a336fdffc5d4e7d05ff27)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: '@typescript/native-preview': 7.0.0-dev.20260707.2 escape-string-regexp: 4.0.0 invariant: 2.2.4 react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) - react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + react-native-worklets@0.8.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.7) - '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7) - '@react-native/metro-config': 0.85.2(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) + '@react-native/metro-config': 0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) convert-source-map: 2.0.0 react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) semver: 7.7.4 transitivePeerDependencies: - supports-color - react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8): + react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8): dependencies: '@jest/create-cache-key-function': 29.7.0 '@react-native/assets-registry': 0.83.10 - '@react-native/codegen': 0.83.10(@babel/core@7.29.7) - '@react-native/community-cli-plugin': 0.83.10(@react-native/metro-config@0.85.2(@babel/core@7.29.7)) + '@react-native/codegen': 0.83.10(@babel/core@7.29.7(supports-color@8.1.1)) + '@react-native/community-cli-plugin': 0.83.10(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)) '@react-native/gradle-plugin': 0.83.10 '@react-native/js-polyfills': 0.83.10 '@react-native/normalize-colors': 0.83.10 - '@react-native/virtualized-lists': 0.83.10(@types/react@19.2.14)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@react-native/virtualized-lists': 0.83.10(@types/react@19.2.14)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) abort-controller: 3.0.0 anser: 1.4.10 ansi-regex: 5.0.1 - babel-jest: 29.7.0(@babel/core@7.29.7) + babel-jest: 29.7.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) babel-plugin-syntax-hermes-parser: 0.32.0 base64-js: 1.5.1 commander: 12.1.0 @@ -14690,7 +14686,7 @@ snapshots: jest-environment-node: 29.7.0 memoize-one: 5.2.1 metro-runtime: 0.83.7 - metro-source-map: 0.83.7 + metro-source-map: 0.83.7(supports-color@8.1.1) nullthrows: 1.1.1 pretty-format: 29.7.0 promise: 8.3.0 @@ -14930,7 +14926,7 @@ snapshots: send@0.19.2: dependencies: - debug: 2.6.9 + debug: 2.6.9(supports-color@8.1.1) depd: 2.0.0 destroy: 1.2.0 encodeurl: 2.0.0 @@ -15308,11 +15304,11 @@ snapshots: ts-dedent@2.3.0: {} - ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.4.0))(typescript@5.9.3): + ts-jest@29.0.5(@babel/core@7.29.7(supports-color@8.1.1))(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.4.0)(supports-color@8.1.1))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 - jest: 29.7.0(@types/node@26.4.0) + jest: 29.7.0(@types/node@26.4.0)(supports-color@8.1.1) jest-util: 29.7.0 json5: 2.2.3 lodash.memoize: 4.1.2 @@ -15321,9 +15317,9 @@ snapshots: typescript: 5.9.3 yargs-parser: 21.1.1 optionalDependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.29.7) + babel-jest: 29.7.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) esbuild: 0.25.4 tsconfig-paths@3.15.0: @@ -15418,6 +15414,8 @@ snapshots: unicode-property-aliases-ecmascript@2.2.0: {} + unimodules-app-loader@55.0.5: {} + universalify@0.2.0: {} unpipe@1.0.0: {} @@ -15498,7 +15496,7 @@ snapshots: tsx: 4.22.4 yaml: 2.9.0 - vitest@4.1.11(@types/node@26.4.0)(happy-dom@20.11.8)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.11(@types/node@26.4.0)(happy-dom@20.11.8)(jsdom@20.0.3(supports-color@8.1.1))(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0)) @@ -15523,7 +15521,7 @@ snapshots: optionalDependencies: '@types/node': 26.4.0 happy-dom: 20.11.8 - jsdom: 20.0.3 + jsdom: 20.0.3(supports-color@8.1.1) transitivePeerDependencies: - msw diff --git a/mobile/pnpm-workspace.yaml b/mobile/pnpm-workspace.yaml index 8f29993b7b4..b6d836a230e 100644 --- a/mobile/pnpm-workspace.yaml +++ b/mobile/pnpm-workspace.yaml @@ -7,5 +7,6 @@ overrides: xcode>uuid: 11.1.1 patchedDependencies: + expo-notifications@55.0.27: patches/expo-notifications@55.0.27.patch react-native-webview@13.16.2: patches/react-native-webview@13.16.2.patch react-native@0.83.10: patches/react-native@0.83.10.patch diff --git a/mobile/src/agent-history/agent-history-scope-paths.test.ts b/mobile/src/agent-history/agent-history-scope-paths.test.ts index 59f16ec9ef8..4dbc6f825ed 100644 --- a/mobile/src/agent-history/agent-history-scope-paths.test.ts +++ b/mobile/src/agent-history/agent-history-scope-paths.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import * as runtimePaths from '../../../src/shared/cross-platform-path' import type { Worktree } from '../worktree/workspace-list-types' import { deriveMobileAiVaultScopePaths } from './agent-history-scope-paths' @@ -57,4 +58,51 @@ describe('deriveMobileAiVaultScopePaths', () => { '/Users/ada/repo/app' ]) }) + + it('retains first spelling and host-specific path equivalence', () => { + const paths = [ + ' /repo/café/ ', + '/repo/cafe\u0301', + '/repo//café', + '/repo/Café', + 'C:\\Repo\\App', + 'c:/repo/app/', + '\\\\server\\share\\App', + '//SERVER/share/app/', + '\\\\wsl.localhost\\Ubuntu\\home\\App', + '//wsl$/ubuntu/home/App/', + '//wsl$/ubuntu/home/app', + '//wsl$/Debian/home/App', + '/repo/back\\slash', + '/repo/back/slash', + 'relative/path', + ' ' + ] + const rows = paths.map((path, index) => worktree({ path, worktreeId: `w-${index}` })) + expect(deriveMobileAiVaultScopePaths('project', rows[0], rows)).toEqual([ + '/repo/café/', + '/repo/Café', + 'C:\\Repo\\App', + '\\\\server\\share\\App', + '\\\\wsl.localhost\\Ubuntu\\home\\App', + '//wsl$/ubuntu/home/app', + '//wsl$/Debian/home/App', + '/repo/back\\slash', + '/repo/back/slash' + ]) + }) + + it('normalizes each candidate once even with many duplicate siblings below the cap', () => { + const rows = Array.from({ length: 1000 }, (_, index) => + worktree({ path: `/repo/app-${index % 32}`, worktreeId: `w-${index}` }) + ) + const normalize = vi.spyOn(runtimePaths, 'normalizeRuntimePathForComparison') + try { + const result = deriveMobileAiVaultScopePaths('project', rows[0], rows) + expect(result).toEqual(Array.from({ length: 32 }, (_, index) => `/repo/app-${index}`)) + expect(normalize).toHaveBeenCalledTimes(rows.length + 1) + } finally { + normalize.mockRestore() + } + }) }) diff --git a/mobile/src/agent-history/agent-history-scope-paths.ts b/mobile/src/agent-history/agent-history-scope-paths.ts index da3b98457d2..e3fda853543 100644 --- a/mobile/src/agent-history/agent-history-scope-paths.ts +++ b/mobile/src/agent-history/agent-history-scope-paths.ts @@ -25,7 +25,8 @@ export function deriveMobileAiVaultScopePaths( } const paths: string[] = [] - addScopePath(paths, activeWorktree.path) + const comparisonPaths = new Set() + addScopePath(paths, comparisonPaths, activeWorktree.path) // Workspace scope = the active worktree only. Project scope additionally // covers same-repo sibling worktrees so the project view stays complete. @@ -37,7 +38,7 @@ export function deriveMobileAiVaultScopePaths( break } if (worktree.repoId === activeWorktree.repoId) { - addScopePath(paths, worktree.path) + addScopePath(paths, comparisonPaths, worktree.path) } } } @@ -45,16 +46,19 @@ export function deriveMobileAiVaultScopePaths( return paths } -function addScopePath(paths: string[], pathValue: string | undefined): void { +function addScopePath( + paths: string[], + comparisonPaths: Set, + pathValue: string | undefined +): void { const trimmedPath = pathValue?.trim() if (!trimmedPath || !isRuntimePathAbsolute(trimmedPath)) { return } const comparisonPath = normalizeRuntimePathForComparison(trimmedPath) - if ( - paths.some((existingPath) => normalizeRuntimePathForComparison(existingPath) === comparisonPath) - ) { + if (comparisonPaths.has(comparisonPath)) { return } + comparisonPaths.add(comparisonPath) paths.push(trimmedPath) } diff --git a/mobile/src/components/MobileMarkdown.file-links.test.ts b/mobile/src/components/MobileMarkdown.file-links.test.ts index 962538a8842..28a3155203a 100644 --- a/mobile/src/components/MobileMarkdown.file-links.test.ts +++ b/mobile/src/components/MobileMarkdown.file-links.test.ts @@ -54,6 +54,21 @@ describe('MobileMarkdown file links', () => { return renderer! } + it('preserves a long unmatched bracket run as literal rendered text', () => { + const text = '['.repeat(60_000) + const tree = render(text) + expect(flattenText(tree.root)).toBe(text) + expect(pressables(tree)).toEqual([]) + }) + + it('preserves nested labels and empty image labels', () => { + const tree = render('[[nested](https://example.com) ![](https://example.com/image)') + pressByText(tree, '[nested') + expect(openURL).toHaveBeenLastCalledWith('https://example.com') + pressByText(tree, 'image') + expect(openURL).toHaveBeenLastCalledWith('https://example.com/image') + }) + it('opens a tapped POSIX absolute path in prose', () => { pressByText(render('Edit /Users/me/wt/src/app.tsx now'), '/Users/me/wt/src/app.tsx') expect(onOpenFile).toHaveBeenCalledWith('/Users/me/wt/src/app.tsx') diff --git a/mobile/src/components/MobileMarkdown.tsx b/mobile/src/components/MobileMarkdown.tsx index 7ecfab8c393..a2bff04cdcd 100644 --- a/mobile/src/components/MobileMarkdown.tsx +++ b/mobile/src/components/MobileMarkdown.tsx @@ -1,3 +1,4 @@ +import { createMarkdownInlineMatcher, type MarkdownInlineMatch } from './markdown-inline-matcher' import { MobileSelectableText } from './MobileSelectableText' import { Fragment, @@ -104,12 +105,15 @@ function renderTextRun( function renderInline(text: string, onOpenFile?: (pathText: string) => void): ReactNode[] { const parts: ReactNode[] = [] - const pattern = - /(!\[[^\]]*\]\([^)]+\)|`[^`]+`|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|\[[^\]]+\]\([^)]+\)|https?:\/\/[^\s<]+)/g + const pattern = createMarkdownInlineMatcher( + text, + /(`[^`]+`|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|https?:\/\/[^\s<]+)/g, + true + ) let pendingStart = 0 - let match: RegExpExecArray | null + let match: MarkdownInlineMatch | null - while ((match = pattern.exec(text))) { + while ((match = pattern.exec())) { const token = match[0] // Intraword `_` runs (snake_case, dunder tails) are literal text per // CommonMark; leaving them unflushed keeps surrounding file paths whole diff --git a/mobile/src/components/markdown-inline-matcher.test.ts b/mobile/src/components/markdown-inline-matcher.test.ts new file mode 100644 index 00000000000..b252a434b0c --- /dev/null +++ b/mobile/src/components/markdown-inline-matcher.test.ts @@ -0,0 +1,103 @@ +import { runInNewContext } from 'node:vm' +import { describe, expect, it } from 'vitest' +import { createMarkdownInlineMatcher } from './markdown-inline-matcher' +import { isIntrawordUnderscoreToken } from './markdown-inline-token-rules' +import { parseInline } from './pr-sidebar/markdown-blocks' + +const ORIGINAL_CHAT = + /(!\[[^\]]*\]\([^)]+\)|`[^`]+`|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|\[[^\]]+\]\([^)]+\)|https?:\/\/[^\s<]+)/g +const CHAT_OTHER = + /(`[^`]+`|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|https?:\/\/[^\s<]+)/g +const ORIGINAL_REVIEW = + /(`[^`]+`)|(\*\*[^*]+\*\*)|(__[^_]+__)|(\*[^*]+\*)|(_[^_]+_)|(\[[^\]]+\]\([^)]+\))/g +const REVIEW_OTHER = /(`[^`]+`)|(\*\*[^*]+\*\*)|(__[^_]+__)|(\*[^*]+\*)|(_[^_]+_)/g + +function tokens(text: string, chat: boolean, original: boolean) { + const pattern = new RegExp(chat ? ORIGINAL_CHAT : ORIGINAL_REVIEW) + const matcher = original + ? { + get lastIndex() { + return pattern.lastIndex + }, + set lastIndex(value) { + pattern.lastIndex = value + }, + exec: () => pattern.exec(text) + } + : createMarkdownInlineMatcher(text, new RegExp(chat ? CHAT_OTHER : REVIEW_OTHER), chat) + const result: Array<{ text: string; index: number; end: number }> = [] + let match + while ((match = matcher.exec())) { + if (chat && isIntrawordUnderscoreToken(text, match.index, match[0])) { + matcher.lastIndex = match.index + 1 + continue + } + result.push({ text: match[0], index: match.index, end: matcher.lastIndex }) + } + return result +} + +describe('mobile inline link scanning', () => { + it.each([false, true])('preserves original token order (chat=%s)', (chat) => { + const fragments = [ + '[', + ']', + '(', + ')', + '!', + 'a', + ' ', + '*', + '**', + '_', + '__', + '`', + '~', + '\n', + '[a](b)', + '![](x)', + 'https://x.y', + '[bad]', + 'foo_bar', + '[[nested](url)' + ] + let seed = 12345 + const random = () => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return seed + } + for (let i = 0; i < 5000; i++) { + const text = Array.from( + { length: 1 + (random() % 40) }, + () => fragments[random() % fragments.length] + ).join('') + expect(tokens(text, chat, false), text).toEqual(tokens(text, chat, true)) + } + }) + + it.each([ + { name: 'unmatched labels', text: '['.repeat(60_000) }, + { name: 'unmatched destinations', text: '[a]('.repeat(12_000) } + ])('keeps $name literal within the parser deadline', ({ text }) => { + const result = runInNewContext('parse(text)', { parse: parseInline, text }, { timeout: 250 }) + expect(result).toEqual([{ kind: 'text', text }]) + }) + + it('does not repeatedly search the suffix for absent non-link tokens', () => { + const text = '[a](b)'.repeat(10_000) + const pattern = new RegExp(REVIEW_OTHER) + const originalExec = pattern.exec.bind(pattern) + let calls = 0 + pattern.exec = (value) => { + calls++ + return originalExec(value) + } + const matcher = createMarkdownInlineMatcher(text, pattern) + let count = 0 + while (matcher.exec()) { + count++ + } + expect(count).toBe(10_000) + expect(calls).toBe(1) + }) +}) diff --git a/mobile/src/components/markdown-inline-matcher.ts b/mobile/src/components/markdown-inline-matcher.ts new file mode 100644 index 00000000000..ba86597f32a --- /dev/null +++ b/mobile/src/components/markdown-inline-matcher.ts @@ -0,0 +1,72 @@ +export type MarkdownInlineMatch = { 0: string; index: number; end: number } + +/** Merge a global non-link regex with links; search starts must advance between calls. */ +export function createMarkdownInlineMatcher( + text: string, + nonLinkPattern: RegExp, + images = false +): { lastIndex: number; exec: () => MarkdownInlineMatch | null } { + let nextOther: MarkdownInlineMatch | null | undefined + let nextLink: MarkdownInlineMatch | null | undefined + let labelEnd = -1 + let destinationEnd = -1 + let noMoreLabels = false + let noMoreDestinations = false + + function findLink(from: number): MarkdownInlineMatch | null { + if (noMoreLabels || noMoreDestinations) { + return null + } + let open = text.indexOf('[', from) + while (open !== -1) { + if (labelEnd < open + 1) { + labelEnd = text.indexOf(']', open + 1) + } + if (labelEnd === -1) { + noMoreLabels = true + return null + } + const image = images && open > from && text[open - 1] === '!' + if ((image || labelEnd > open + 1) && text[labelEnd + 1] === '(') { + if (destinationEnd < labelEnd + 2) { + destinationEnd = text.indexOf(')', labelEnd + 2) + } + if (destinationEnd === -1) { + noMoreDestinations = true + return null + } + if (destinationEnd > labelEnd + 2) { + const index = image ? open - 1 : open + return { 0: text.slice(index, destinationEnd + 1), index, end: destinationEnd + 1 } + } + } + // Every opener before this closing bracket shares the same invalid suffix. + open = text.indexOf('[', labelEnd + 1) + } + return null + } + + const matcher = { + lastIndex: 0, + exec(): MarkdownInlineMatch | null { + const from = matcher.lastIndex + if (nextOther === undefined || (nextOther !== null && nextOther.index < from)) { + nonLinkPattern.lastIndex = from + const match = nonLinkPattern.exec(text) + nextOther = match + ? { 0: match[0], index: match.index, end: nonLinkPattern.lastIndex } + : null + } + if (nextLink === undefined || (nextLink !== null && nextLink.index < from)) { + nextLink = findLink(from) + } + const match = + nextLink && (!nextOther || nextLink.index < nextOther.index) ? nextLink : nextOther + if (match) { + matcher.lastIndex = match.end + } + return match + } + } + return matcher +} diff --git a/mobile/src/components/mobile-markdown-parser-progress.test.ts b/mobile/src/components/mobile-markdown-parser-progress.test.ts new file mode 100644 index 00000000000..e5cf3181d0c --- /dev/null +++ b/mobile/src/components/mobile-markdown-parser-progress.test.ts @@ -0,0 +1,46 @@ +import { runInNewContext } from 'node:vm' +import { describe, expect, it } from 'vitest' +import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html' +import { parseMobileMarkdown } from './mobile-markdown-parser' + +function parseWithDeadline(input: string) { + // A synchronous parser loop must fail without hanging the test worker. + return runInNewContext('parse(input)', { parse: parseMobileMarkdown, input }, { timeout: 250 }) +} + +describe('mobile Markdown parser progress', () => { + it.each(['```c++', '```c#', '``` ts', '```ts title="file.ts"', '````', '```!'])( + 'retains unsupported fence %s as paragraph text', + (fence) => { + expect(parseWithDeadline(fence)).toEqual([{ type: 'paragraph', text: fence }]) + expect(parseWithDeadline(`before\n${fence}\nafter`)).toEqual([ + { type: 'paragraph', text: `before\n${fence}\nafter` } + ]) + } + ) + + it.each(['# ', '## ', '###### ', '#\t'])('consumes incomplete heading %s', (heading) => { + expect(parseWithDeadline(`${heading}\nnext`)).toEqual([ + { type: 'paragraph', text: `${heading}\nnext` } + ]) + }) + + it('handles unsupported fences through the production preview normalization', () => { + const input = normalizeMobileMarkdownPreviewHtml('```c++\nint x = 1;') + expect(parseWithDeadline(input)).toEqual([{ type: 'paragraph', text: input }]) + }) + + it('recognizes a supported fence after unsupported fence text', () => { + expect(parseWithDeadline('```c++\n```ts\nconst x = 1\n```')).toEqual([ + { type: 'paragraph', text: '```c++' }, + { type: 'code', text: 'const x = 1', language: 'ts', closed: true } + ]) + }) + + it('preserves supported fences and their streaming state', () => { + expect(parseWithDeadline('before\n```ts\nconst x = 1')).toEqual([ + { type: 'paragraph', text: 'before' }, + { type: 'code', text: 'const x = 1', language: 'ts', closed: false } + ]) + }) +}) diff --git a/mobile/src/components/mobile-markdown-parser.ts b/mobile/src/components/mobile-markdown-parser.ts index fa16b3b9310..2ee83236007 100644 --- a/mobile/src/components/mobile-markdown-parser.ts +++ b/mobile/src/components/mobile-markdown-parser.ts @@ -8,6 +8,9 @@ export type MobileMarkdownBlock = | { type: 'table'; headers: string[]; rows: string[][] } | { type: 'rule' } +const HEADING = /^(#{1,6})\s+(.+)$/ +const CODE_FENCE = /^```([A-Za-z0-9_-]+)?\s*$/ + function splitTableRow(line: string): string[] { return line .trim() @@ -34,7 +37,7 @@ export function parseMobileMarkdown(content: string): MobileMarkdownBlock[] { continue } - const fence = line.match(/^```([A-Za-z0-9_-]+)?\s*$/) + const fence = line.match(CODE_FENCE) if (fence) { index += 1 const code: string[] = [] @@ -80,7 +83,7 @@ export function parseMobileMarkdown(content: string): MobileMarkdownBlock[] { continue } - const heading = line.match(/^(#{1,6})\s+(.+)$/) + const heading = line.match(HEADING) if (heading) { blocks.push({ type: 'heading', level: heading[1]!.length, text: heading[2]!.trim() }) index += 1 @@ -121,8 +124,8 @@ export function parseMobileMarkdown(content: string): MobileMarkdownBlock[] { while ( index < lines.length && lines[index]?.trim() && - !(lines[index] ?? '').startsWith('```') && - !/^(#{1,6})\s+/.test(lines[index] ?? '') && + !CODE_FENCE.test(lines[index] ?? '') && + !HEADING.test(lines[index] ?? '') && !/^>\s?/.test(lines[index] ?? '') && !/^\s*(?:[-*+]|\d+[.)])\s+/.test(lines[index] ?? '') && !/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(lines[index] ?? '') diff --git a/mobile/src/components/pr-sidebar/markdown-blocks.ts b/mobile/src/components/pr-sidebar/markdown-blocks.ts index d5866585b68..1f53bbdbae9 100644 --- a/mobile/src/components/pr-sidebar/markdown-blocks.ts +++ b/mobile/src/components/pr-sidebar/markdown-blocks.ts @@ -1,3 +1,5 @@ +import { createMarkdownInlineMatcher } from '../markdown-inline-matcher' + // Tiny, dependency-free markdown model for PR comment bodies. We render GitHub // markdown without a third-party RN markdown library (the previous dependency hung // the JS thread when a comment list mounted). Scope is deliberately small — the @@ -30,8 +32,6 @@ const HEADING = /^(#{1,6})\s+(.*)$/ const FENCE = /^```/ // Captures the fence info string (language) on the opening fence, e.g. ```mermaid. const FENCE_OPEN = /^```\s*([^\s`]*)/ -// A GFM table delimiter row: cells of dashes with optional leading/trailing colons. -const TABLE_DELIM = /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/ const QUOTE = /^>\s?(.*)$/ const HR = /^(?:---+|\*\*\*+|___+)\s*$/ const UNORDERED = /^\s*[-*+]\s+(.*)$/ @@ -114,7 +114,7 @@ function parseLines(content: string): MarkdownBlock[] { // GFM pipe table: a header row immediately followed by a delimiter row. // Requires the delimiter row so plain prose with a stray `|` isn't captured. - if (line.includes('|') && i + 1 < lines.length && TABLE_DELIM.test(lines[i + 1])) { + if (line.includes('|') && i + 1 < lines.length && isTableDelimiter(lines[i + 1])) { flushParagraph() const headers = splitTableRow(line) const align = parseAlignRow(lines[i + 1]) @@ -217,6 +217,10 @@ function splitTableRow(line: string): string[] { return cells } +function isTableDelimiter(line: string): boolean { + return splitTableRow(line).every((cell) => /^:?-+:?$/.test(cell)) +} + // Reads alignment from a delimiter row's colons: `:--` left, `:-:` center, `--:` right. function parseAlignRow(line: string): CellAlign[] { return splitTableRow(line).map((spec) => { @@ -234,23 +238,25 @@ function parseAlignRow(line: string): CellAlign[] { // Inline emphasis/code/link tokenizer. Walks the string once, longest-match first, // emitting plain-text runs between matches. Unbalanced markers stay literal text. -const INLINE = /(`[^`]+`)|(\*\*[^*]+\*\*)|(__[^_]+__)|(\*[^*]+\*)|(_[^_]+_)|(\[[^\]]+\]\([^)]+\))/ +const INLINE = /(`[^`]+`)|(\*\*[^*]+\*\*)|(__[^_]+__)|(\*[^*]+\*)|(_[^_]+_)/g export function parseInline(text: string): InlineToken[] { const tokens: InlineToken[] = [] // Strip residual inline HTML tags (, , , …) so they don't render // literally; emphasis/code/links below are markdown, not HTML, so this is safe. - let rest = stripHtmlTags(text) + const plain = stripHtmlTags(text) + const matcher = createMarkdownInlineMatcher(plain, INLINE) + let cursor = 0 let guard = 0 - while (rest.length > 0 && guard < 5000) { + while (cursor < plain.length && guard < 5000) { guard += 1 - const m = INLINE.exec(rest) + const m = matcher.exec() if (!m || m.index === undefined) { - tokens.push({ kind: 'text', text: rest }) + tokens.push({ kind: 'text', text: plain.slice(cursor) }) break } - if (m.index > 0) { - tokens.push({ kind: 'text', text: rest.slice(0, m.index) }) + if (m.index > cursor) { + tokens.push({ kind: 'text', text: plain.slice(cursor, m.index) }) } const token = m[0] if (token.startsWith('`')) { @@ -267,7 +273,7 @@ export function parseInline(text: string): InlineToken[] { } else { tokens.push({ kind: 'italic', text: token.slice(1, -1) }) } - rest = rest.slice(m.index + token.length) + cursor = matcher.lastIndex } return tokens } diff --git a/mobile/src/components/pr-sidebar/markdown-table-delimiter-performance.test.ts b/mobile/src/components/pr-sidebar/markdown-table-delimiter-performance.test.ts new file mode 100644 index 00000000000..2f167feb747 --- /dev/null +++ b/mobile/src/components/pr-sidebar/markdown-table-delimiter-performance.test.ts @@ -0,0 +1,36 @@ +import { runInNewContext } from 'node:vm' +import { describe, expect, it } from 'vitest' +import { parseMarkdownBlocks } from './markdown-blocks' + +const ORIGINAL_DELIMITER = /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/ + +function isParsedTable(delimiter: string): boolean { + return parseMarkdownBlocks(`a|b\n${delimiter}`)[0]?.kind === 'table' +} + +describe('review Markdown table delimiter cost', () => { + it.each([ + { name: 'leading whitespace', delimiter: ' '.repeat(60_000) + 'x' }, + { name: 'trailing cell whitespace', delimiter: '-|-' + ' '.repeat(60_000) + 'x' } + ])('rejects $name without backtracking', ({ delimiter }) => { + // Interrupt synchronous regex regressions instead of hanging the test worker. + const blocks = runInNewContext( + 'parse(input)', + { parse: parseMarkdownBlocks, input: `a|b\n${delimiter}` }, + { timeout: 250 } + ) + expect(blocks).toEqual([{ kind: 'paragraph', text: `a|b\n${delimiter}` }]) + }) + + it('preserves the original delimiter grammar over generated rows', () => { + const parts = ['', '-', '--', ':', ':-:', ':--', '--:', '|', ' ', '\t', '\r', '\\|', 'x'] + for (const left of parts) { + for (const middle of parts) { + for (const right of parts) { + const row = left + middle + right + expect(isParsedTable(row), JSON.stringify(row)).toBe(ORIGINAL_DELIMITER.test(row)) + } + } + } + }) +}) diff --git a/mobile/src/diagnostics/troubleshoot-common-issues.tsx b/mobile/src/diagnostics/troubleshoot-common-issues.tsx index b794ad004d5..31fdb17cf85 100644 --- a/mobile/src/diagnostics/troubleshoot-common-issues.tsx +++ b/mobile/src/diagnostics/troubleshoot-common-issues.tsx @@ -1,4 +1,4 @@ -import { WifiOff, Shield, Monitor, Clock, Globe } from 'lucide-react-native' +import { WifiOff, Shield, Monitor, Clock, Globe, Bell } from 'lucide-react-native' import { colors } from '../theme/mobile-theme' export type TroubleshootSection = { @@ -9,6 +9,16 @@ export type TroubleshootSection = { } export const troubleshootCommonIssues: TroubleshootSection[] = [ + { + id: 'notifications', + icon: , + title: 'Push Notifications', + steps: [ + 'Check that system settings allow Orca notifications and that Focus or Do Not Disturb is off.', + 'Try cellular or another Wi-Fi network. If alerts arrive after switching, your network may be delaying delivery.' + ] + }, + { id: 'wifi', icon: , diff --git a/mobile/src/notifications/NotificationDeliverySection.test.tsx b/mobile/src/notifications/NotificationDeliverySection.test.tsx new file mode 100644 index 00000000000..8b7971b6086 --- /dev/null +++ b/mobile/src/notifications/NotificationDeliverySection.test.tsx @@ -0,0 +1,37 @@ +import { createElement } from 'react' +import { act, create } from 'react-test-renderer' +import { expect, it, vi } from 'vitest' +import { NotificationDeliverySection } from './NotificationDeliverySection' +import { DEFAULT_NOTIFICATION_DELIVERY } from './notification-delivery-preferences' + +vi.mock('@react-native-async-storage/async-storage', () => ({ default: {} })) +vi.mock('react-native', () => ({ + StyleSheet: { create: (value: unknown) => value }, + View: 'View', + Text: 'Text', + Switch: 'Switch' +})) + +it('shows only phone-specific controls while desktop owns category eligibility', () => { + const onChange = vi.fn() + let renderer: ReturnType + act(() => { + renderer = create( + createElement(NotificationDeliverySection, { value: DEFAULT_NOTIFICATION_DELIVERY, onChange }) + ) + }) + const switches = () => renderer.root.findAllByType('Switch' as never) + expect(switches().map((node) => node.props.accessibilityLabel)).toEqual([ + 'Only when away from desktop', + 'Notification sound', + 'Suppress while focused' + ]) + expect(JSON.stringify(renderer.toJSON())).toContain( + 'Alert types follow each paired desktop’s notification settings.' + ) + act(() => switches()[0].props.onValueChange(false)) + expect(onChange).toHaveBeenLastCalledWith( + expect.objectContaining({ onlyWhenDesktopAway: false, sound: true, suppressWhileViewing: true }) + ) + act(() => renderer.unmount()) +}) diff --git a/mobile/src/notifications/NotificationDeliverySection.tsx b/mobile/src/notifications/NotificationDeliverySection.tsx new file mode 100644 index 00000000000..df1f3dc0683 --- /dev/null +++ b/mobile/src/notifications/NotificationDeliverySection.tsx @@ -0,0 +1,72 @@ +import { StyleSheet, Switch, Text, View } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import type { NotificationDeliveryPreferences } from './notification-delivery-preferences' + +type Props = { + value: NotificationDeliveryPreferences + disabled?: boolean + onChange: (value: NotificationDeliveryPreferences) => void +} + +export function NotificationDeliverySection({ value, disabled, onChange }: Props) { + const row = (key: keyof NotificationDeliveryPreferences, label: string, hint?: string) => { + return ( + + + {label} + {hint && {hint}} + + onChange({ ...value, [key]: enabled })} + trackColor={{ false: colors.bgRaised, true: colors.textSecondary }} + thumbColor={colors.textPrimary} + /> + + ) + } + return ( + <> + + {row( + 'onlyWhenDesktopAway', + 'Only when away from desktop', + 'After 3 minutes without keyboard or mouse activity, or when locked.' + )} + {row('sound', 'Notification sound')} + {row( + 'suppressWhileViewing', + 'Suppress while focused', + 'Skip alerts for the workspace open on this phone.' + )} + + + Alert types follow each paired desktop’s notification settings. Notifications pause after 7 + days without using this app; open it and reconnect to resume. + + + ) +} + +const styles = StyleSheet.create({ + section: { + backgroundColor: colors.bgPanel, + borderRadius: radii.card, + overflow: 'hidden', + marginTop: spacing.md + }, + row: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, padding: spacing.md }, + labelGroup: { flex: 1, gap: spacing.xs }, + label: { fontSize: typography.bodySize, fontWeight: '500', color: colors.textPrimary }, + hint: { fontSize: typography.metaSize, color: colors.textMuted }, + disabled: { opacity: 0.5 }, + footer: { + fontSize: typography.metaSize, + color: colors.textMuted, + marginTop: spacing.md, + paddingHorizontal: spacing.sm + } +}) diff --git a/mobile/src/notifications/android-foreground-push.test.ts b/mobile/src/notifications/android-foreground-push.test.ts new file mode 100644 index 00000000000..cad381e7ee9 --- /dev/null +++ b/mobile/src/notifications/android-foreground-push.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import type { Notification } from 'expo-notifications' +import { startAndroidForegroundPushPresentation } from './android-foreground-push' + +const mocks = vi.hoisted(() => ({ + platform: { OS: 'android' }, + receive: (_notification: Notification) => {}, + remove: vi.fn(), + eligible: vi.fn().mockResolvedValue(true), + schedule: vi.fn().mockResolvedValue('message-1') +})) +vi.mock('./push-receive', () => ({ canPresentForegroundPush: mocks.eligible })) +vi.mock('react-native', () => ({ Platform: mocks.platform })) +vi.mock('expo-notifications', () => ({ + addNotificationReceivedListener: (listener: typeof mocks.receive) => { + mocks.receive = listener + return { remove: mocks.remove } + }, + scheduleNotificationAsync: mocks.schedule +})) + +function notification(trigger: unknown = { type: 'push', remoteMessage: { notification: null } }) { + return { + request: { + identifier: 'message-1', + trigger, + content: { + title: 'Test notification', + body: '', + sound: 'default', + data: { + hostFingerprint: 'host', + notificationId: 'event', + notificationEpoch: 'epoch', + notificationSeq: '3', + paneKey: 'pane', + channelId: 'orca-desktop' + } + } + } + } as unknown as Notification +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.eligible.mockReset().mockResolvedValue(true) + mocks.platform.OS = 'android' +}) + +it('presents a title-only data push with its original identity, routing and channel', async () => { + const stop = startAndroidForegroundPushPresentation() + const incoming = notification() + mocks.receive(incoming) + await vi.waitFor(() => expect(mocks.schedule).toHaveBeenCalledOnce()) + expect(mocks.schedule).toHaveBeenCalledWith({ + identifier: incoming.request.identifier, + content: incoming.request.content, + trigger: { channelId: 'orca-desktop' } + }) + stop() + expect(mocks.remove).toHaveBeenCalledOnce() +}) + +it('does not reschedule its own local notification or normal provider notifications', () => { + startAndroidForegroundPushPresentation() + mocks.receive(notification(null)) + mocks.receive(notification({ type: 'channel', channelId: 'orca-desktop' })) + mocks.receive(notification({ type: 'push', remoteMessage: { notification: { title: 'Test' } } })) + expect(mocks.schedule).not.toHaveBeenCalled() +}) + +it('leaves silent dismissals and unrelated messages alone', () => { + startAndroidForegroundPushPresentation() + const incoming = notification() + incoming.request.content.data.kind = 'dismiss' + mocks.receive(incoming) + incoming.request.content.data = {} + mocks.receive(incoming) + expect(mocks.schedule).not.toHaveBeenCalled() +}) + +it('leaves iOS delivery unchanged', () => { + mocks.platform.OS = 'ios' + startAndroidForegroundPushPresentation()() + expect(mocks.remove).not.toHaveBeenCalled() +}) + +it('waits for eligibility before scheduling, even if native presentation will bypass JS', async () => { + let resolve!: (eligible: boolean) => void + mocks.eligible.mockReturnValue( + new Promise((done) => { + resolve = done + }) + ) + startAndroidForegroundPushPresentation() + mocks.receive(notification()) + expect(mocks.schedule).not.toHaveBeenCalled() + // Model a dismissal arriving while the eligibility reads are in flight. + resolve(false) + await Promise.resolve() + expect(mocks.schedule).not.toHaveBeenCalled() +}) + +it('does not schedule when eligibility cannot be read', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + mocks.eligible.mockRejectedValueOnce(new Error('storage unavailable')) + startAndroidForegroundPushPresentation() + mocks.receive(notification()) + await vi.waitFor(() => expect(warn).toHaveBeenCalledOnce()) + expect(mocks.schedule).not.toHaveBeenCalled() + warn.mockRestore() +}) diff --git a/mobile/src/notifications/android-foreground-push.ts b/mobile/src/notifications/android-foreground-push.ts new file mode 100644 index 00000000000..cbff78d0bf2 --- /dev/null +++ b/mobile/src/notifications/android-foreground-push.ts @@ -0,0 +1,49 @@ +import { Platform } from 'react-native' +import * as Notifications from 'expo-notifications' +import { canPresentForegroundPush } from './push-receive' +import { readOrcaPushPayload } from './push-payload' + +export function startAndroidForegroundPushPresentation(): () => void { + if (Platform.OS !== 'android') { + return () => {} + } + + const subscription = Notifications.addNotificationReceivedListener((notification) => { + const { trigger, content, identifier } = notification.request + // Expo emits foreground data pushes but only auto-presents them in the background. + if ( + !trigger || + !('type' in trigger) || + trigger.type !== 'push' || + trigger.remoteMessage?.notification !== null + ) { + return + } + const payload = readOrcaPushPayload(content.data) + if (!payload || payload.kind === 'dismiss' || (!content.title && !content.body)) { + return + } + + void present().catch((error: unknown) => { + console.warn('[push] Foreground notification presentation failed', error) + }) + + async function present(): Promise { + if (!payload || !(await canPresentForegroundPush(payload))) { + return + } + await Notifications.scheduleNotificationAsync({ + identifier, + content: { + title: content.title, + body: content.body, + data: content.data, + sound: content.sound === 'default' ? 'default' : false + }, + trigger: + typeof content.data?.channelId === 'string' ? { channelId: content.data.channelId } : null + }) + } + }) + return () => subscription.remove() +} diff --git a/mobile/src/notifications/desktop-notification-channel.test.ts b/mobile/src/notifications/desktop-notification-channel.test.ts new file mode 100644 index 00000000000..95ff41ebe35 --- /dev/null +++ b/mobile/src/notifications/desktop-notification-channel.test.ts @@ -0,0 +1,62 @@ +import { readFileSync } from 'node:fs' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as Notifications from 'expo-notifications' +import { Platform } from 'react-native' +import { + DESKTOP_NOTIFICATION_CHANNEL_ID, + ensureDesktopNotificationChannel +} from './desktop-notification-channel' + +vi.mock('expo-notifications', () => ({ + AndroidImportance: { HIGH: 'high' }, + setNotificationChannelAsync: vi.fn() +})) + +vi.mock('react-native', () => ({ + AppState: { currentState: 'background' }, + Platform: { OS: 'android' } +})) + +beforeEach(() => { + vi.clearAllMocks() + Object.assign(Platform, { OS: 'android' }) + vi.mocked(Notifications.setNotificationChannelAsync).mockResolvedValue(null as never) +}) + +describe('ensureDesktopNotificationChannel', () => { + it('creates the channel the gateway payload names', async () => { + await ensureDesktopNotificationChannel() + + expect(Notifications.setNotificationChannelAsync).toHaveBeenCalledWith( + 'orca-desktop', + expect.objectContaining({ importance: 'high' }) + ) + expect(DESKTOP_NOTIFICATION_CHANNEL_ID).toBe('orca-desktop') + }) + + it('does nothing on iOS, which has no notification channels', () => { + Object.assign(Platform, { OS: 'ios' }) + + ensureDesktopNotificationChannel() + + expect(Notifications.setNotificationChannelAsync).not.toHaveBeenCalled() + }) + + it('reports channel failure so registration can retry', async () => { + vi.mocked(Notifications.setNotificationChannelAsync).mockRejectedValue(new Error('no channels')) + + await expect(ensureDesktopNotificationChannel()).rejects.toThrow('no channels') + }) +}) + +describe('app boot', () => { + it('creates the channel at startup, not only once a socket subscribes', () => { + // A background push can be the first thing to target 'orca-desktop', and Android + // drops a notification whose channel does not exist. Asserted against the source + // because vitest only collects src/, so app/_layout.tsx has no runtime coverage. + const layout = readFileSync(new URL('../../app/_layout.tsx', import.meta.url), 'utf8') + + expect(layout).toContain("from '../src/notifications/desktop-notification-channel'") + expect(layout).toMatch(/^void ensureDesktopNotificationChannel\(\)\.catch\(/m) + }) +}) diff --git a/mobile/src/notifications/desktop-notification-channel.ts b/mobile/src/notifications/desktop-notification-channel.ts new file mode 100644 index 00000000000..cce1f73d582 --- /dev/null +++ b/mobile/src/notifications/desktop-notification-channel.ts @@ -0,0 +1,27 @@ +import * as Notifications from 'expo-notifications' +import { Platform } from 'react-native' + +// Why an id both sides share: the gateway's FCM payload names this channel, so a +// background push can be the first thing that ever targets it. Android drops a +// notification whose channel does not exist, and the channel used to be created +// only inside subscribeToDesktopNotifications — i.e. only once a socket connected. +export const DESKTOP_NOTIFICATION_CHANNEL_ID = 'orca-desktop' + +/** Idempotent on Android (the OS updates the existing channel); a no-op elsewhere. */ +export async function ensureDesktopNotificationChannel(): Promise { + if (Platform.OS !== 'android') { + return + } + await Notifications.setNotificationChannelAsync(`${DESKTOP_NOTIFICATION_CHANNEL_ID}-silent`, { + name: 'Orca silent notifications', + importance: Notifications.AndroidImportance.HIGH, + sound: null, + enableVibrate: false + }) + await Notifications.setNotificationChannelAsync(DESKTOP_NOTIFICATION_CHANNEL_ID, { + name: 'Desktop Notifications', + importance: Notifications.AndroidImportance.HIGH, + vibrationPattern: [0, 250], + lightColor: '#6366f1' + }) +} diff --git a/mobile/src/notifications/desktop-notification-events.ts b/mobile/src/notifications/desktop-notification-events.ts new file mode 100644 index 00000000000..b6e7492b648 --- /dev/null +++ b/mobile/src/notifications/desktop-notification-events.ts @@ -0,0 +1,6 @@ +export type DismissNotificationEvent = { + type: 'dismiss' + notificationId: string + notificationSeq?: number + notificationEpoch?: string +} diff --git a/mobile/src/notifications/expo-native-token-retry.test.ts b/mobile/src/notifications/expo-native-token-retry.test.ts new file mode 100644 index 00000000000..7fabcd6555c --- /dev/null +++ b/mobile/src/notifications/expo-native-token-retry.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, expect, it, vi } from 'vitest' + +const native = vi.hoisted(() => vi.fn()) +vi.mock('expo-modules-core', () => ({ Platform: { OS: 'ios' }, UnavailabilityError: Error })) +vi.mock('expo-notifications/build/PushTokenManager', () => ({ + default: { getDevicePushTokenAsync: native } +})) +vi.mock('expo-notifications/build/warnOfExpoGoPushUsage', () => ({ + warnOfExpoGoPushUsage: () => {} +})) + +beforeEach(() => { + vi.resetModules() + native.mockReset() +}) + +it('releases a failed Expo native-token request so the next attempt can succeed', async () => { + const { getDevicePushTokenAsync } = + await import('expo-notifications/build/getDevicePushTokenAsync') + native.mockRejectedValueOnce(new Error('APNs unavailable')).mockResolvedValueOnce('device-token') + await expect(getDevicePushTokenAsync()).rejects.toThrow('APNs unavailable') + await expect(getDevicePushTokenAsync()).resolves.toEqual({ type: 'ios', data: 'device-token' }) + expect(native).toHaveBeenCalledTimes(2) +}) + +it('still shares one pending native request between concurrent callers', async () => { + const { getDevicePushTokenAsync } = + await import('expo-notifications/build/getDevicePushTokenAsync') + let resolve!: (token: string) => void + native.mockReturnValue( + new Promise((done) => { + resolve = done + }) + ) + const first = getDevicePushTokenAsync() + const second = getDevicePushTokenAsync() + expect(native).toHaveBeenCalledOnce() + resolve('device-token') + expect(await first).toEqual(await second) +}) diff --git a/mobile/src/notifications/local-notification-scheduling.ts b/mobile/src/notifications/local-notification-scheduling.ts deleted file mode 100644 index f511346250e..00000000000 --- a/mobile/src/notifications/local-notification-scheduling.ts +++ /dev/null @@ -1,191 +0,0 @@ -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 - dismissAfterSchedule?: boolean -} - -const scheduledNotificationsByHostAndNotificationId = new Map() - -// 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 { - 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 { - 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(() => {}) -} diff --git a/mobile/src/notifications/mobile-notifications.test.ts b/mobile/src/notifications/mobile-notifications.test.ts index d85b1363005..ba784520f98 100644 --- a/mobile/src/notifications/mobile-notifications.test.ts +++ b/mobile/src/notifications/mobile-notifications.test.ts @@ -1,969 +1,59 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import * as Notifications from 'expo-notifications' -import { Platform } from 'react-native' -import { - getNotificationPermissionState, - setScheduledNotificationsMaxForTests, - subscribeToDesktopNotifications -} from './mobile-notifications' -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' +import { subscribeToDesktopNotifications } from './mobile-notifications' +import { dismissHostPushNotification } from './push-socket-dismissal' +import { requestNotificationCatchup } from './push-dismissal-reconciliation' -vi.mock('expo-notifications', () => ({ - AndroidImportance: { HIGH: 'high' }, - setNotificationChannelAsync: vi.fn(), - getPermissionsAsync: vi.fn(), - requestPermissionsAsync: vi.fn(), - scheduleNotificationAsync: vi.fn(), - dismissNotificationAsync: vi.fn() +vi.mock('./push-socket-dismissal', () => ({ + dismissHostPushNotification: vi.fn(async () => {}) })) - -vi.mock('react-native', () => ({ - Platform: { OS: 'ios', Version: 18 } +vi.mock('./push-dismissal-reconciliation', () => ({ + requestNotificationCatchup: vi.fn(async () => {}) })) +vi.mock('./notification-permissions', () => ({})) -// Why: mobile-notifications now persists the catch-up watermark to -// AsyncStorage. The package isn't resolvable in the node test env (other -// mobile tests mock it the same way), so we provide a no-op mock. -vi.mock('@react-native-async-storage/async-storage', () => ({ - default: { - getItem: vi.fn(async () => null), - setItem: vi.fn(async () => undefined) - } -})) +type Handler = (data: unknown) => void -vi.mock('../storage/preferences', () => ({ - loadPushNotificationsEnabled: vi.fn() -})) - -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', () => { - it.each([ - { os: 'android', version: 32, expected: false }, - { os: 'android', version: 33, expected: true }, - { os: 'ios', version: 18, expected: true } - ])( - 'reports whether a granted $os $version authorization reflects user choice', - async ({ os, version, expected }) => { - Object.assign(Platform, { OS: os, Version: version }) - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ - status: 'granted', - canAskAgain: true - } as never) - - await expect(getNotificationPermissionState()).resolves.toMatchObject({ - granted: true, - authorizationReflectsUserChoice: expected - }) +function client() { + let handler: Handler | undefined + return { + getState: vi.fn(() => 'connected'), + sendRequest: vi.fn(async () => ({ ok: true })), + subscribe: vi.fn((_method: string, _params: unknown, callback: Handler) => { + handler = callback + return vi.fn() + }), + emit(data: unknown) { + handler?.(data) } - ) -}) + } +} + +beforeEach(() => vi.clearAllMocks()) describe('subscribeToDesktopNotifications', () => { - beforeEach(() => { - vi.clearAllMocks() + it('never presents an OS banner for socket alert or replay events', async () => { + const rpc = client() + subscribeToDesktopNotifications(rpc as never, 'host-1') + rpc.emit({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' }) + rpc.emit({ + type: 'notification', + notificationId: 'agent-1', + title: 'Needs input', + body: 'Reply', + source: 'agent-task-complete' + }) + await Promise.resolve() + expect(requestNotificationCatchup).toHaveBeenCalledWith(rpc, 'host-1', expect.any(Function)) + expect(dismissHostPushNotification).not.toHaveBeenCalled() }) - // 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 { - return new Promise((resolve) => { - setTimeout(resolve, 0) - }) - } - - function makeDeferred(): { promise: Promise; resolve: (value: T) => void } { - let resolve!: (value: T) => void - const promise = new Promise((next) => { - resolve = next - }) - return { promise, resolve } - } - - it('drops the local stream when disposed before the desktop returns ready', () => { - const unsubscribeStream = vi.fn() - const client = { - subscribe: vi.fn(() => unsubscribeStream), - getState: vi.fn(() => 'connected'), - sendRequest: vi.fn() - } as unknown as RpcClient - - const unsubscribe = subscribeToDesktopNotifications(client, 'host-1') - unsubscribe() - - expect(unsubscribeStream).toHaveBeenCalledTimes(1) - expect(client.sendRequest).not.toHaveBeenCalled() - }) - - it('stores scheduled notification identifiers, replaces duplicates, and dismisses by id', async () => { - vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ - status: 'granted', - canAskAgain: true - } as never) - vi.mocked(Notifications.scheduleNotificationAsync) - .mockResolvedValueOnce('scheduled-1') - .mockResolvedValueOnce('scheduled-2') - vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined) - let onEvent: ((data: unknown) => void) | null = null - const client = { - subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => { - onEvent = callback - return vi.fn() - }), - getState: vi.fn(() => 'connected'), - sendRequest: vi.fn() - } as unknown as RpcClient - - subscribeToDesktopNotifications(client, 'host-1') - onEvent?.({ - type: 'notification', - source: 'agent-task-complete', - title: 'Done', - body: 'Finished.', - worktreeId: 'repo::/tmp/worktree', - notificationId: 'agent:one' - }) - await flushAsync() - onEvent?.({ - type: 'notification', - source: 'agent-task-complete', - title: 'Done again', - body: 'Finished again.', - notificationId: 'agent:one' - }) - await flushAsync() - expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(2) - onEvent?.({ type: 'dismiss', notificationId: 'agent:one' }) - await flushAsync() - - expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(2) - expect(Notifications.scheduleNotificationAsync).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - content: expect.objectContaining({ - data: expect.objectContaining({ - hostId: 'host-1', - notificationId: 'agent:one', - worktreeId: 'repo::/tmp/worktree' - }) - }) - }) - ) - expect(Notifications.dismissNotificationAsync).toHaveBeenNthCalledWith(1, 'scheduled-1') - expect(Notifications.dismissNotificationAsync).toHaveBeenNthCalledWith(2, 'scheduled-2') - }) - - it('dedupes concurrent notification events with the same desktop notification id', async () => { - vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ - status: 'granted', - canAskAgain: true - } as never) - vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1') - let onEvent: ((data: unknown) => void) | null = null - const client = { - subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => { - onEvent = callback - return vi.fn() - }), - getState: vi.fn(() => 'connected'), - sendRequest: vi.fn() - } as unknown as RpcClient - - subscribeToDesktopNotifications(client, 'host-concurrent') - onEvent?.({ - type: 'notification', - source: 'agent-task-complete', - title: 'Done', - body: 'Finished.', - notificationId: 'agent:concurrent' - }) - onEvent?.({ - type: 'notification', - source: 'agent-task-complete', - title: 'Done', - body: 'Finished.', - notificationId: 'agent:concurrent' - }) - await flushAsync() - - expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(1) - }) - - it('dismisses a notification when dismiss arrives while scheduling is pending', async () => { - vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ - status: 'granted', - canAskAgain: true - } as never) - let resolveSchedule!: (identifier: string) => void - vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation( - () => - new Promise((resolve) => { - resolveSchedule = resolve - }) - ) - vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined) - let onEvent: ((data: unknown) => void) | null = null - const client = { - subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => { - onEvent = callback - return vi.fn() - }), - getState: vi.fn(() => 'connected'), - sendRequest: vi.fn() - } as unknown as RpcClient - - subscribeToDesktopNotifications(client, 'host-dismiss-race') - onEvent?.({ - type: 'notification', - source: 'agent-task-complete', - title: 'Done', - body: 'Finished.', - notificationId: 'agent:pending' - }) - await flushAsync() - onEvent?.({ type: 'dismiss', notificationId: 'agent:pending' }) - resolveSchedule('scheduled-pending') - await flushAsync() - - expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('scheduled-pending') - }) - - it('does not carry a failed pending dismiss into a future schedule', async () => { - const secondEnabled = makeDeferred() - vi.mocked(loadPushNotificationsEnabled) - .mockResolvedValueOnce(true) - .mockReturnValueOnce(secondEnabled.promise) - .mockResolvedValueOnce(true) - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ - status: 'granted', - canAskAgain: true - } as never) - vi.mocked(Notifications.scheduleNotificationAsync) - .mockResolvedValueOnce('scheduled-1') - .mockResolvedValueOnce('scheduled-2') - vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined) - let onEvent: ((data: unknown) => void) | null = null - const client = { - subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => { - onEvent = callback - return vi.fn() - }), - getState: vi.fn(() => 'connected'), - sendRequest: vi.fn() - } as unknown as RpcClient - - subscribeToDesktopNotifications(client, 'host-dismiss-failed-replacement') - onEvent?.({ - type: 'notification', - source: 'agent-task-complete', - title: 'Done', - body: 'Finished.', - notificationId: 'agent:stale-dismiss' - }) - await flushAsync() - onEvent?.({ - type: 'notification', - source: 'agent-task-complete', - title: 'Done again', - body: 'Finished again.', - notificationId: 'agent:stale-dismiss' - }) - await flushAsync() - onEvent?.({ type: 'dismiss', notificationId: 'agent:stale-dismiss' }) - secondEnabled.resolve(false) - await flushAsync() - - onEvent?.({ - type: 'notification', - source: 'agent-task-complete', - title: 'Done later', - body: 'Finished later.', - notificationId: 'agent:stale-dismiss' - }) - await flushAsync() - - expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(2) - expect(Notifications.dismissNotificationAsync).toHaveBeenCalledTimes(1) - expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('scheduled-1') - }) - - it('treats unknown dismiss events as no-ops', async () => { - vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined) - let onEvent: ((data: unknown) => void) | null = null - const client = { - subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => { - onEvent = callback - return vi.fn() - }), - getState: vi.fn(() => 'connected'), - sendRequest: vi.fn() - } as unknown as RpcClient - - subscribeToDesktopNotifications(client, 'host-unknown') - onEvent?.({ type: 'dismiss', notificationId: 'agent:missing' }) - await flushAsync() - - expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalled() - }) - - // Why: notificationId is unique per completion, so the map grew unbounded when - // the desktop never sent a dismiss (the remote-mobile case). It is now capped. - it('evicts the oldest scheduled entry once the cap is exceeded', async () => { - setScheduledNotificationsMaxForTests(1) - try { - vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ - status: 'granted', - canAskAgain: true - } as never) - vi.mocked(Notifications.scheduleNotificationAsync) - .mockResolvedValueOnce('scheduled-old') - .mockResolvedValueOnce('scheduled-new') - vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined) - let onEvent: ((data: unknown) => void) | null = null - const client = { - subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => { - onEvent = callback - return vi.fn() - }), - getState: vi.fn(() => 'connected'), - sendRequest: vi.fn() - } as unknown as RpcClient - - subscribeToDesktopNotifications(client, 'host-1') - onEvent?.({ type: 'notification', title: 't', body: 'b', notificationId: 'agent:old' }) - await flushAsync() - onEvent?.({ type: 'notification', title: 't', body: 'b', notificationId: 'agent:new' }) - await flushAsync() - - // The older entry was evicted by the cap: dismissing it is a no-op... - onEvent?.({ type: 'dismiss', notificationId: 'agent:old' }) - await flushAsync() - expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalledWith('scheduled-old') - - // ...while the most-recent entry is retained and still dismissable. - onEvent?.({ type: 'dismiss', notificationId: 'agent:new' }) - await flushAsync() - expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('scheduled-new') - } finally { - setScheduledNotificationsMaxForTests() - } - }) -}) - -// Why: #8129 catch-up. On a reconnect the live stream re-emits `ready`; the -// client must fetch missed notifications from its watermark and push exactly -// the ones it had not yet delivered — never re-pushing an already-delivered id. -describe('subscribeToDesktopNotifications — reconnect catch-up', () => { - const AsyncStorageMock = vi.mocked(AsyncStorage) - - beforeEach(() => { - vi.clearAllMocks() - AsyncStorageMock.getItem.mockResolvedValue(null) - vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined) - }) - - function flushAsync(): Promise { - return new Promise((resolve) => { - setTimeout(resolve, 10) - }) - } - - function makeClient() { - let onData: ((data: unknown) => void) | null = null - const sentRequests: { method: string; params: unknown }[] = [] - const client = { - subscribe: vi.fn((_method: string, _params: unknown, cb: (data: unknown) => void) => { - onData = cb - return vi.fn() - }), - getState: vi.fn(() => 'connected'), - sendRequest: vi.fn( - async (method: string, _params: unknown = {}) => - ({ - ok: true, - result: method === 'notifications.getMissedSince' ? { notifications: [] } : undefined - }) as never - ) - } - // Why: onData is captured live via a getter (not destructured) because the - // subscribe mock assigns it asynchronously as a side effect of - // subscribeToDesktopNotifications calling client.subscribe. - return { - client: client as unknown as RpcClient, - get onData() { - return onData - }, - sentRequests - } - } - - it('does not fetch missed notifications on the first (cold-open) ready', async () => { - vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ - status: 'granted', - canAskAgain: true - } as never) - vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1') - - const sub = makeClient() - subscribeToDesktopNotifications(sub.client, 'host-1') - // First ready = cold open. - sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' }) - await flushAsync() - - expect(sub.client.sendRequest).not.toHaveBeenCalledWith( - 'notifications.getMissedSince', - expect.anything() - ) - }) - - it('fetches only notifications after the delivered watermark (idempotent catch-up)', async () => { - vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ - status: 'granted', - canAskAgain: true - } as never) - vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1') - - const sub = makeClient() - // The desktop honours the watermark: only seq 10 (agent:missed) is returned - // because seq 11 (agent:dup) was already delivered on the live stream and - // advanced lastDeliveredSeq to 11. So the replay never re-includes it. - sub.client.sendRequest = vi.fn(async (method: string) => { - if (method === 'notifications.getMissedSince') { - return { - ok: true, - result: { - notifications: [ - { - type: 'notification', - title: 'missed', - body: 'b', - notificationId: 'agent:missed', - notificationSeq: 10 - } - ] - } - } as never - } - return { ok: true, result: undefined } as never - }) - - subscribeToDesktopNotifications(sub.client, 'host-1') - // First ready = cold open (no fetch). - sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' }) - await flushAsync() - // Live stream already delivered agent:dup (seq 11) before reap. - sub.onData?.({ - type: 'notification', - title: 'dup', - body: 'b', - notificationId: 'agent:dup', - notificationSeq: 11 - }) - await flushAsync() - // Reconnect ready → fetchMissed sends the watermark (11). - sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' }) - await flushAsync() - await flushAsync() - - // The watermark passed to getMissedSince is the delivered seq. - const missedCall = vi - .mocked(sub.client.sendRequest) - .mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince') - expect(missedCall?.[1]).toEqual({ lastSeenSeq: 11 }) - // Only agent:missed was pushed; agent:dup appears exactly once (live only). - const scheduledIds = vi - .mocked(Notifications.scheduleNotificationAsync) - .mock.calls.map( - (call) => - (call[0] as { content: { data: { notificationId: string } } }).content.data.notificationId - ) - expect(scheduledIds).toEqual(['agent:dup', 'agent:missed']) - 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((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({ - status: 'granted', - canAskAgain: true - } as never) - vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s') - - const sub = makeClient() - // Simulate the bounded-buffer edge: the desktop returns seq 11 again - // (already delivered live) alongside a new seq 12. - sub.client.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: 11 - }, - { - type: 'notification', - title: 'new', - body: 'b', - notificationId: 'agent:new', - notificationSeq: 12 - } - ] - } - } as never - } - return { ok: true, result: undefined } as never - }) - - subscribeToDesktopNotifications(sub.client, 'host-1') - sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' }) - await flushAsync() - // Live stream delivered agent:dup (seq 11) before reap. - sub.onData?.({ - type: 'notification', - title: 'dup', - body: 'b', - notificationId: 'agent:dup', - notificationSeq: 11 - }) - await flushAsync() - // Reconnect replay re-includes seq 11 (must be dropped) + new seq 12. - sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' }) - await flushAsync() - await flushAsync() - - const scheduledIds = vi - .mocked(Notifications.scheduleNotificationAsync) - .mock.calls.map( - (call) => - (call[0] as { content: { data: { notificationId: string } } }).content.data.notificationId - ) - expect(scheduledIds).toEqual(['agent:dup', 'agent:new']) - expect(scheduledIds.filter((id) => id === 'agent:dup')).toHaveLength(1) - }) - - it('persists the highest delivered seq so a later reconnect resumes from it', async () => { - 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' }) - await flushAsync() - // Live stream delivers seq 5. - sub.onData?.({ - type: 'notification', - title: 't', - body: 'b', - notificationId: 'agent:live', - notificationSeq: 5 - }) - await flushAsync() - - expect(AsyncStorageMock.setItem).toHaveBeenCalledWith( - 'orca:mobileNotificationsWatermark:host-1', - JSON.stringify({ seq: 5, epoch: null }) - ) - }) - - // Why: a replay-ONLY delivery (nothing arrived live first) must still advance - // and persist the watermark. This is the exact case the seq/notificationSeq - // field mismatch broke — the desktop replay path returns `notificationSeq` - // (matching the live fan-out), so the client watermark moves and the next - // reconnect resumes from it instead of re-fetching from 0. - it('advances + persists the watermark from a replay-only delivery (#8129 field-mismatch regression)', async () => { - 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() - // Desktop replay returns events keyed by notificationSeq (the fixed shape). - sub.client.sendRequest = vi.fn(async (method: string) => { - if (method === 'notifications.getMissedSince') { - return { - ok: true, - result: { - notifications: [ - { - type: 'notification', - title: 'missed', - body: 'b', - notificationId: 'agent:missed', - notificationSeq: 8 - } - ] - } - } as never - } - return { ok: true, result: undefined } as never - }) - - subscribeToDesktopNotifications(sub.client, 'host-1') - sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' }) - await flushAsync() - // First reconnect → replay delivers seq 8 (no prior live delivery). - sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' }) - await flushAsync() - await flushAsync() - - // Watermark advanced to the replayed seq and was persisted. - expect(AsyncStorageMock.setItem).toHaveBeenCalledWith( - 'orca:mobileNotificationsWatermark:host-1', - JSON.stringify({ seq: 8, epoch: null }) - ) - - // Second reconnect resumes from the advanced watermark, not 0. - sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' }) - await flushAsync() - const missedCalls = vi - .mocked(sub.client.sendRequest) - .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' - }) + it('keeps socket dismissal processing active', async () => { + const rpc = client() + subscribeToDesktopNotifications(rpc as never, 'host-1') + rpc.emit({ type: 'ready', subscriptionId: 'sub-1' }) + const dismissal = { type: 'dismiss', notificationId: 'agent-1', notificationSeq: 4 } + rpc.emit(dismissal) + await Promise.resolve() + expect(dismissHostPushNotification).toHaveBeenCalledWith(dismissal, 'host-1') }) }) diff --git a/mobile/src/notifications/mobile-notifications.ts b/mobile/src/notifications/mobile-notifications.ts index 0043762e3ec..974c9516263 100644 --- a/mobile/src/notifications/mobile-notifications.ts +++ b/mobile/src/notifications/mobile-notifications.ts @@ -1,211 +1,22 @@ +import { requestNotificationCatchup } from './push-dismissal-reconciliation' +import { dismissHostPushNotification } from './push-socket-dismissal' +import type { DismissNotificationEvent } from './desktop-notification-events' import type { RpcClient } from '../transport/rpc-client' -// 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 { - configureNotificationChannel, - dismissLocalNotification, - showLocalNotification, - type DismissNotificationEvent, - type NotificationEvent -} from './local-notification-scheduling' -import { - adoptNotificationEpoch, - catchUpWatermarkSeq, - enqueueHostDelivery, - getHostNotificationSession, - quarantineCatchUpWatermark, - releaseQueuedShowNotificationId, - resolveCatchUpQuarantine, - saveWatermark, - seedWatermarkFromStorage, - seenKeyForEvent, - shouldQueueShowForNotificationId -} from './notification-reconnect-catchup' type SubscribeResult = { type: 'ready' subscriptionId: string - // 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. export function subscribeToDesktopNotifications(client: RpcClient, hostId: string): () => void { - configureNotificationChannel() - let subscriptionId: string | null = null let disposed = false - // 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) - - /** - * 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 { - 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) - } - } - // Why swallowed: the caller is an un-awaited handler, so a rejected show would - // surface as an unhandled rejection (a RN redbox) instead of being retried by - // the next catch-up — which is now possible, since `seen` is marked after the show. - }).catch(() => {}) - } - - async function deliverLive( - type: 'notification' | 'dismiss', - event: NotificationEvent | DismissNotificationEvent - ): Promise { - adoptNotificationEpoch(session, hostId, event.notificationEpoch) - const epochAtDelivery = session.lastDeliveredEpoch - if (type === 'notification') { - await showLocalNotification(event as NotificationEvent, hostId) - } else { - await dismissLocalNotification(event as DismissNotificationEvent, hostId) - } - // Why after the await, exactly like the watermark below: `seen` asserts this event - // reached the user (#8129). Marked before, a rejected show leaves the key behind and - // every later replay is dropped as a duplicate — loss the quarantine cannot recover, - // since the first event to drain a batch lifts it past the one never shown. - const key = seenKeyForEvent(event) - // A mid-flight epoch adoption already cleared the counter lifetime this key indexes. - if (key && session.lastDeliveredEpoch === epochAtDelivery) { - session.seen.add(key) - } - // 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 - // Why clamped: while a failed catch-up's range is still unrecovered, persisting - // the live seq would let the next catch-up ask from above the gap and the desktop - // would cut it. resolveCatchUpQuarantine writes the held-back value on success. - void saveWatermark(hostId, { - seq: catchUpWatermarkSeq(session), - epoch: session.lastDeliveredEpoch - }) - } - } - - // 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. - async function deliverMissedEvent( - event: NotificationEvent | DismissNotificationEvent - ): Promise { - // No pre-marking here either: deliverLive marks the key once the show lands. - const key = seenKeyForEvent(event) - if (key && session.seen.has(key)) { - return - } - if (event.type === 'notification') { - if (!shouldQueueShowForNotificationId(session, event.notificationId)) { - return - } - try { - await deliverLive('notification', event) - } finally { - releaseQueuedShowNotificationId(session, event.notificationId) - } - return - } - if (event.type === 'dismiss') { - await deliverLive('dismiss', event) - } - } - - // Why: desktop cuts by seq > lastSeenSeq, so re-fetching from the watermark is idempotent (session.seen guards residual overlap). - async function fetchMissed(): Promise { - if (disposed) { - return - } - // Captured before the request: everything at or below it is known delivered, so - // it is the floor the watermark falls back to if this catch-up never completes. - const askFrom = catchUpWatermarkSeq(session) - const missed = await client - .sendRequest('notifications.getMissedSince', { - lastSeenSeq: askFrom, - // 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 null - } - const result = response.result as { notifications?: unknown[]; epoch?: string } | undefined - adoptNotificationEpoch(session, hostId, result?.epoch) - return Array.isArray(result?.notifications) ? result.notifications : [] - }) - .catch(() => null) - if (missed == null) { - // Why quarantine rather than retry: the range this catch-up abandoned stays - // unrecovered until SOME later one succeeds, and a live seq persisting past it - // meanwhile would make the desktop cut it forever. - quarantineCatchUpWatermark(session, hostId, askFrom) - return - } - // 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 () => { - // Advances only past events this batch settled, so a teardown or a failing show - // quarantines the true contiguous point instead of the range it never reached. - let contiguousSeq = askFrom - let drained = false - try { - 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 - await deliverMissedEvent(event) - contiguousSeq = event.notificationSeq ?? contiguousSeq - } - drained = true - } finally { - if (drained) { - resolveCatchUpQuarantine(session, hostId) - } else { - quarantineCatchUpWatermark(session, hostId, contiguousSeq) - } - } - // Why swallowed here: the `finally` above already recorded the contiguous point, - // and the only caller is an un-awaited 'ready' continuation — letting a failed - // show escape turns every one into an unhandled rejection (a RN redbox). - }).catch(() => {}) - } - - seedWatermarkFromStorage(session, hostId) function unsubscribeServer(id: string) { if (client.getState() === 'connected') { @@ -213,79 +24,28 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin } } - const unsubscribeStream = client.subscribe('notifications.subscribe', {}, (data: unknown) => { - const event = data as - | NotificationEvent - | DismissNotificationEvent - | SubscribeResult - | { type: 'end' } + const params = { includeDesktopSuppressed: true } + const unsubscribeStream = client.subscribe('notifications.subscribe', params, (data: unknown) => { + const event = data as DismissNotificationEvent | SubscribeResult | { type: string } if (event.type === 'ready') { subscriptionId = (event as SubscribeResult).subscriptionId - const isReconnect = session.connectedBefore - session.connectedBefore = true if (disposed) { unsubscribeServer(subscriptionId) unsubscribeStream() return } - 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() - } - })() + // A max watermark asks only which delivered pushes are stale; socket history + // never becomes a second OS-notification delivery route. + void requestNotificationCatchup(client, hostId, () => disposed).catch(() => {}) return } - if (event.type === 'end') { - if (disposed) { - unsubscribeStream() - } - return + if (!disposed && event.type === 'dismiss') { + void dismissHostPushNotification(event as DismissNotificationEvent, hostId).catch(() => {}) } - if (disposed) { - return - } - 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 () => { disposed = true - // Why: drop the local stream first — readiness can race unmount; don't hold the callback while a subscription id is pending. unsubscribeStream() if (subscriptionId) { unsubscribeServer(subscriptionId) diff --git a/mobile/src/notifications/mobile-push-lease-renewal.test.ts b/mobile/src/notifications/mobile-push-lease-renewal.test.ts new file mode 100644 index 00000000000..1f440e93abc --- /dev/null +++ b/mobile/src/notifications/mobile-push-lease-renewal.test.ts @@ -0,0 +1,39 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { AppState } from 'react-native' +import { startMobilePushLeaseRenewal } from './mobile-push-lease-renewal' + +let onChange: (state: string) => void +const remove = vi.fn() +vi.mock('react-native', () => ({ + AppState: { + currentState: 'active', + addEventListener: (_: string, callback: typeof onChange) => { + onChange = callback + return { remove } + } + } +})) +afterEach(() => { + vi.useRealTimers() + vi.clearAllMocks() +}) + +it('renews only while mobile is foregrounded, resumes on return, and tears down', async () => { + vi.useFakeTimers() + AppState.currentState = 'active' + const renew = vi.fn(async () => {}) + const stop = startMobilePushLeaseRenewal(renew) + await vi.advanceTimersByTimeAsync(15 * 60_000) + expect(renew).toHaveBeenCalledTimes(1) + AppState.currentState = 'background' + onChange('background') + await vi.advanceTimersByTimeAsync(8 * 24 * 60 * 60_000) + expect(renew).toHaveBeenCalledTimes(1) + AppState.currentState = 'active' + onChange('active') + expect(renew).toHaveBeenCalledTimes(2) + stop() + await vi.advanceTimersByTimeAsync(15 * 60_000) + expect(renew).toHaveBeenCalledTimes(2) + expect(remove).toHaveBeenCalledOnce() +}) diff --git a/mobile/src/notifications/mobile-push-lease-renewal.ts b/mobile/src/notifications/mobile-push-lease-renewal.ts new file mode 100644 index 00000000000..22d34b2a401 --- /dev/null +++ b/mobile/src/notifications/mobile-push-lease-renewal.ts @@ -0,0 +1,19 @@ +import { AppState } from 'react-native' + +export function startMobilePushLeaseRenewal(renew: () => Promise): () => void { + const refresh = () => { + if (AppState.currentState === 'active') { + void renew().catch(() => {}) + } + } + const subscription = AppState.addEventListener('change', (state) => { + if (state === 'active') { + refresh() + } + }) + const timer = setInterval(refresh, 15 * 60_000) + return () => { + subscription.remove() + clearInterval(timer) + } +} diff --git a/mobile/src/notifications/native-notification-data.test.ts b/mobile/src/notifications/native-notification-data.test.ts new file mode 100644 index 00000000000..2b157a5fda6 --- /dev/null +++ b/mobile/src/notifications/native-notification-data.test.ts @@ -0,0 +1,22 @@ +import { expect, it } from 'vitest' +import { readNativeNotificationData } from './native-notification-data' +import { readOrcaPushPayload } from './push-payload' + +it('reads actual Expo APNs payloads when content.data is null', () => { + const orca = { + hostFingerprint: 'qa-host', + notificationId: 'done', + notificationSeq: 4, + notificationEpoch: 'epoch' + } + const data = readNativeNotificationData({ + content: { data: null }, + trigger: { type: 'push', payload: { aps: {}, orca } } + }) + expect(readOrcaPushPayload(data)).toMatchObject(orca) +}) +it('keeps Android push and local notification data', () => { + const data = { hostId: 'host', notificationId: 'done' } + expect(readNativeNotificationData({ content: { data }, trigger: { type: 'push' } })).toBe(data) + expect(readNativeNotificationData({ content: { data }, trigger: null })).toBe(data) +}) diff --git a/mobile/src/notifications/native-notification-data.ts b/mobile/src/notifications/native-notification-data.ts new file mode 100644 index 00000000000..74d50397660 --- /dev/null +++ b/mobile/src/notifications/native-notification-data.ts @@ -0,0 +1,13 @@ +export function readNativeNotificationData(request: { + content: { data?: unknown } + trigger?: unknown +}): unknown { + const trigger = request.trigger + if (trigger && typeof trigger === 'object' && 'type' in trigger && trigger.type === 'push') { + // Expo iOS keeps raw APNs custom fields here when content.data is null. + if ('payload' in trigger && trigger.payload && typeof trigger.payload === 'object') { + return trigger.payload + } + } + return request.content.data +} diff --git a/mobile/src/notifications/native-push-dismissal.ios.ts b/mobile/src/notifications/native-push-dismissal.ios.ts new file mode 100644 index 00000000000..72e46ff9fec --- /dev/null +++ b/mobile/src/notifications/native-push-dismissal.ios.ts @@ -0,0 +1,4 @@ +import { requireNativeModule } from 'expo-modules-core' +import type { NativeDismissal } from './native-push-dismissal' + +export const nativePushDismissal = requireNativeModule('OrcaNotificationDismissal') diff --git a/mobile/src/notifications/native-push-dismissal.test.ts b/mobile/src/notifications/native-push-dismissal.test.ts new file mode 100644 index 00000000000..73c96495300 --- /dev/null +++ b/mobile/src/notifications/native-push-dismissal.test.ts @@ -0,0 +1,23 @@ +import { beforeEach, expect, it, vi } from 'vitest' + +const requireNativeModule = vi.hoisted(() => vi.fn()) +vi.mock('expo-modules-core', () => ({ requireNativeModule })) + +beforeEach(() => { + vi.resetModules() + requireNativeModule.mockReset() +}) + +it('requires the iOS ledger and surfaces a missing native module as a build defect', async () => { + requireNativeModule.mockImplementation(() => { + throw new Error('Cannot find native module OrcaNotificationDismissal') + }) + await expect(import('./native-push-dismissal.ios')).rejects.toThrow( + 'Cannot find native module OrcaNotificationDismissal' + ) +}) + +it('does not load an iOS module on the default Android/web path', async () => { + expect((await import('./native-push-dismissal')).nativePushDismissal).toBeNull() + expect(requireNativeModule).not.toHaveBeenCalled() +}) diff --git a/mobile/src/notifications/native-push-dismissal.ts b/mobile/src/notifications/native-push-dismissal.ts new file mode 100644 index 00000000000..a676c68e6dc --- /dev/null +++ b/mobile/src/notifications/native-push-dismissal.ts @@ -0,0 +1,8 @@ +import type { OrcaPushPayload } from './push-payload' + +export type NativeDismissal = { + remember(payload: OrcaPushPayload): Promise + wasDismissed(payload: OrcaPushPayload): Promise +} +// Android and web use JavaScript storage; iOS requires the native ledger. +export const nativePushDismissal: NativeDismissal | null = null diff --git a/mobile/src/notifications/notification-catchup-failure-quarantine.test.ts b/mobile/src/notifications/notification-catchup-failure-quarantine.test.ts deleted file mode 100644 index 997b9fce930..00000000000 --- a/mobile/src/notifications/notification-catchup-failure-quarantine.test.ts +++ /dev/null @@ -1,316 +0,0 @@ -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() - -vi.mock('@react-native-async-storage/async-storage', () => ({ - default: { - getItem: vi.fn(async (key: string) => storage.get(key) ?? null), - setItem: vi.fn(async (key: string, value: string) => { - storage.set(key, value) - }) - } -})) - -vi.mock('../storage/preferences', () => ({ - loadPushNotificationsEnabled: vi.fn() -})) - -function flushAsync(): Promise { - return new Promise((resolve) => { - setTimeout(resolve, 10) - }) -} - -function persistedSeq(): number { - return (JSON.parse(storage.get(WATERMARK_KEY) ?? '{}') as { seq?: number }).seq ?? 0 -} - -type MissedOutcome = - | { kind: 'reject' } - | { kind: 'notOk' } - | { kind: 'ok'; notifications: unknown[] } - // Rejects only once `settle()` is called, so a live event can land mid-request. - | { kind: 'heldReject' } - -function makeHostClient() { - let onData: ((data: unknown) => void) | null = null - const askedFrom: number[] = [] - let outcome: MissedOutcome = { kind: 'ok', notifications: [] } - let releaseHeld: (() => void) | null = null - 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') { - return { ok: true, result: undefined } as never - } - askedFrom.push((params as { lastSeenSeq: number }).lastSeenSeq) - if (outcome.kind === 'heldReject') { - await new Promise((resolve) => { - releaseHeld = resolve - }) - throw new Error('socket closed') - } - if (outcome.kind === 'reject') { - throw new Error('socket closed') - } - if (outcome.kind === 'notOk') { - return { ok: false, error: { message: 'timeout' } } as never - } - return { ok: true, result: { notifications: outcome.notifications } } as never - }) - } - return { - client: client as unknown as RpcClient, - get onData() { - return onData - }, - askedFrom, - setOutcome(next: MissedOutcome) { - outcome = next - }, - settleHeld() { - releaseHeld?.() - } - } -} - -function notification(seq: number) { - return { - type: 'notification', - title: `m${seq}`, - body: 'b', - notificationId: `agent:${seq}`, - notificationSeq: seq - } -} - -describe('#8591 catch-up failure quarantines the watermark', () => { - 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) - }) - - it('keeps asking from the abandoned range until a catch-up actually succeeds', async () => { - // The phone was offline while seqs 6-7 dispatched. The catch-up that would have - // replayed them dies (socket close / timeout / ok:false), and live traffic keeps - // flowing. If a live seq is allowed to persist past 6-7, the desktop cuts by - // `seq > lastSeenSeq` on the next catch-up and they are gone for good — and the - // window stays open until some catch-up succeeds, not for one round trip. - storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' })) - const host = makeHostClient() - host.setOutcome({ kind: 'reject' }) - - subscribeToDesktopNotifications(host.client, 'host-1') - host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' }) - await flushAsync() - expect(host.askedFrom).toEqual([5]) - - host.onData?.({ ...notification(11), notificationEpoch: 'epoch-1' }) - await flushAsync() - expect(persistedSeq()).toBe(5) - - // Second catch-up also fails; the gap is still open. - host.setOutcome({ kind: 'notOk' }) - host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' }) - await flushAsync() - host.onData?.({ ...notification(12), notificationEpoch: 'epoch-1' }) - await flushAsync() - expect(host.askedFrom).toEqual([5, 5]) - expect(persistedSeq()).toBe(5) - - // Third succeeds and replays the abandoned range. - host.setOutcome({ kind: 'ok', notifications: [notification(6), notification(7)] }) - host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' }) - await flushAsync() - - expect(host.askedFrom).toEqual([5, 5, 5]) - const titles = vi - .mocked(Notifications.scheduleNotificationAsync) - .mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title) - // Exact, not arrayContaining: a duplicate here is the double-push `seen` prevents. - // m11/m12 are the live events that kept flowing while the gap stayed open. - expect(titles).toEqual(['m11', 'm12', 'm6', 'm7']) - - // Only now may the watermark move past the recovered range. - expect(persistedSeq()).toBe(12) - host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' }) - await flushAsync() - expect(host.askedFrom).toEqual([5, 5, 5, 12]) - }) - - it('rolls back a watermark a live event stored while the catch-up was in flight', async () => { - // getMissedSince waits up to 30s, so live traffic routinely persists during it. - // Clamping only writes made AFTER the failure leaves that higher seq on disk, and - // the next launch reads it back and resumes past the range this catch-up abandoned. - storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' })) - const host = makeHostClient() - host.setOutcome({ kind: 'heldReject' }) - - subscribeToDesktopNotifications(host.client, 'host-1') - host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' }) - await flushAsync() - expect(host.askedFrom).toEqual([5]) - - host.onData?.({ ...notification(11), notificationEpoch: 'epoch-1' }) - await flushAsync() - expect(persistedSeq()).toBe(11) - - host.settleHeld() - await flushAsync() - expect(persistedSeq()).toBe(5) - }) - - it('quarantines at the last replayed seq when a teardown cuts the batch short', async () => { - // The batch can start before a teardown and still be draining after it, so the - // events past the interruption were never shown. A live seq arriving on the next - // connection must not persist over them. - storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' })) - const host = makeHostClient() - host.setOutcome({ - kind: 'ok', - notifications: [notification(6), notification(7), notification(8)] - }) - - let unsubscribe: (() => void) | null = null - vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async (request) => { - if ((request as { content: { title: string } }).content.title === 'm6') { - unsubscribe?.() - } - return 'sched-1' - }) - - unsubscribe = subscribeToDesktopNotifications(host.client, 'host-1') - host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' }) - await flushAsync() - - const titles = vi - .mocked(Notifications.scheduleNotificationAsync) - .mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title) - expect(titles).toEqual(['m6']) - - // A fresh subscription on the same module-scope session takes a live seq 20 before - // its own catch-up, then resumes from 6 rather than from 20. - vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1') - const host2 = makeHostClient() - host2.setOutcome({ kind: 'ok', notifications: [notification(7), notification(8)] }) - subscribeToDesktopNotifications(host2.client, 'host-1') - host2.onData?.({ ...notification(20), notificationEpoch: 'epoch-1' }) - await flushAsync() - expect(persistedSeq()).toBe(6) - - host2.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-1' }) - await flushAsync() - - expect(host2.askedFrom).toEqual([6]) - expect( - vi - .mocked(Notifications.scheduleNotificationAsync) - .mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title) - ).toEqual(['m6', 'm20', 'm7', 'm8']) - expect(persistedSeq()).toBe(20) - }) - - it('re-shows a replay whose show threw, instead of dropping it as already seen', async () => { - // The quarantine only holds the RANGE. If the failing event is also marked seen, - // the next catch-up re-fetches it and the dedup guard drops it — the banner is - // never shown, and the first later event to drain the batch lifts the quarantine - // past it. Silent loss with the watermark looking healthy. - storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' })) - const host = makeHostClient() - host.setOutcome({ kind: 'ok', notifications: [notification(6), notification(7)] }) - - let failNext = true - vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async (request) => { - const title = (request as { content: { title: string } }).content.title - if (title === 'm6' && failNext) { - failNext = false - throw new Error('scheduling rejected') - } - return 'sched-1' - }) - - subscribeToDesktopNotifications(host.client, 'host-1') - host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' }) - await flushAsync() - expect(persistedSeq()).toBe(5) - - host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' }) - await flushAsync() - - const titles = vi - .mocked(Notifications.scheduleNotificationAsync) - .mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title) - expect(titles).toEqual(['m6', 'm6', 'm7']) - expect(host.askedFrom).toEqual([5, 5]) - expect(persistedSeq()).toBe(7) - }) - - it('re-shows a live event whose show threw, instead of dropping it as already seen', async () => { - // The same hole without any catch-up failing: the live path marks seen before the - // show, so a rejected show leaves the key behind while the watermark stays put. - // The next catch-up dutifully re-fetches the seq and the guard eats it. - storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' })) - const host = makeHostClient() - host.setOutcome({ kind: 'ok', notifications: [] }) - - let failNext = true - vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async () => { - if (failNext) { - failNext = false - throw new Error('scheduling rejected') - } - return 'sched-1' - }) - - subscribeToDesktopNotifications(host.client, 'host-1') - host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' }) - await flushAsync() - - host.onData?.({ ...notification(6), notificationEpoch: 'epoch-1' }) - await flushAsync() - expect(persistedSeq()).toBe(5) - - host.setOutcome({ kind: 'ok', notifications: [notification(6)] }) - host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' }) - await flushAsync() - - const titles = vi - .mocked(Notifications.scheduleNotificationAsync) - .mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title) - expect(titles).toEqual(['m6', 'm6']) - expect(persistedSeq()).toBe(6) - }) -}) diff --git a/mobile/src/notifications/notification-consent-ownership.test.ts b/mobile/src/notifications/notification-consent-ownership.test.ts new file mode 100644 index 00000000000..6d729d0db02 --- /dev/null +++ b/mobile/src/notifications/notification-consent-ownership.test.ts @@ -0,0 +1,310 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import AsyncStorage from '@react-native-async-storage/async-storage' +import NotificationsScreen from '../../app/notifications' +import MobileOnboardingScreen from '../../app/mobile-onboarding' +import { shouldPresentNotificationOptIn } from './notification-opt-in-gate' +import { + attachPushRegistration, + NOTIFICATIONS_REMOTE_PUSH_CAPABILITY, + resetPushRegistrationForTests, + setRemotePushEnabled, + startPushTokenSync +} from './push-registration' +import { getDevicePushToken } from './push-token' + +const mocks = vi.hoisted(() => ({ storage: new Map(), replace: vi.fn() })) +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: vi.fn(async (key: string) => mocks.storage.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => { + mocks.storage.set(key, value) + }) + } +})) +vi.mock('react-native', () => ({ + AppState: { currentState: 'active', addEventListener: () => ({ remove: vi.fn() }) }, + AccessibilityInfo: { + addEventListener: () => ({ remove: vi.fn() }), + isReduceMotionEnabled: async () => false + }, + Animated: { Value: class {}, View: 'View', multiply: () => 0 }, + BackHandler: { addEventListener: () => ({ remove: vi.fn() }) }, + StyleSheet: { create: (styles: unknown) => styles }, + Text: 'Text', + View: 'View', + Switch: 'Switch', + ScrollView: 'ScrollView', + Pressable: 'Pressable', + Alert: { alert: vi.fn() }, + Linking: { openSettings: vi.fn() }, + useWindowDimensions: () => ({ width: 390, height: 844 }) +})) +vi.mock('expo-router', () => ({ + useFocusEffect: vi.fn(), + useLocalSearchParams: () => ({ hostId: 'host', steps: 'notifications' }), + useRouter: () => ({ replace: mocks.replace }) +})) +vi.mock('react-native-safe-area-context', () => ({ + SafeAreaView: 'View', + useSafeAreaInsets: () => ({ top: 0, bottom: 0 }) +})) +vi.mock('lucide-react-native', () => ({ ChevronLeft: 'Icon' })) +vi.mock('../components/OrcaLogo', () => ({ OrcaLogo: 'Logo' })) +vi.mock('../onboarding/MobileOnboardingPage', () => ({ MobileOnboardingPage: 'Page' })) +vi.mock('../transport/use-all-host-clients', () => ({ useAllHostClients: () => [] })) +vi.mock('../transport/host-store', () => ({ loadHostCatalog: async () => [] })) +vi.mock('./NotificationDeliverySection', () => ({ NotificationDeliverySection: 'Delivery' })) +vi.mock('./use-remote-push-capable-hosts', () => ({ useRemotePushCapableHosts: () => [] })) +vi.mock('./notification-permissions', () => ({ + ensureNotificationPermissions: async () => true, + getNotificationPermissionState: async () => ({ + granted: true, + status: 'granted', + canAskAgain: true, + authorizationReflectsUserChoice: true + }) +})) +vi.mock('./mobile-notifications', () => ({ + ensureNotificationPermissions: async () => true, + getNotificationPermissionState: async () => ({ + granted: true, + status: 'granted', + canAskAgain: true, + authorizationReflectsUserChoice: true + }) +})) +vi.mock('./desktop-notification-channel', () => ({ + ensureDesktopNotificationChannel: async () => {} +})) +vi.mock('./push-token', () => ({ + getDevicePushToken: vi.fn(), + addPushTokenListener: () => () => {} +})) + +const token = { + platform: 'ios' as const, + token: 'a'.repeat(64), + apnsEnvironment: 'sandbox' as const +} +let renderer: ReactTestRenderer | undefined +let stopSync: () => void +const records = () => JSON.parse(mocks.storage.get('orca:remotePushHostRegistrations') ?? '{}') +const drain = () => vi.advanceTimersByTimeAsync(0) +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} +function connection() { + return { + sendRequest: vi.fn(async (method: string): Promise => ({ + ok: true, + result: + method === 'status.get' + ? { capabilities: [NOTIFICATIONS_REMOTE_PUSH_CAPABILITY] } + : { registered: true, unregistered: true } + })) + } +} +async function connectedHost() { + const client = connection() + attachPushRegistration('host', client as never) + await drain() + client.sendRequest.mockClear() + return client +} +async function choose(entry: string) { + await act(async () => { + renderer = create( + createElement(entry === 'settings' ? NotificationsScreen : MobileOnboardingScreen) + ) + }) + await act(async () => { + if (entry === 'settings') { + renderer!.root.findByType('Switch').props.onValueChange(true) + } else { + renderer!.root.findByType('Page').props.onNotificationChoice('enable') + } + }) +} +function expectChoiceComplete(entry: string) { + expect(mocks.storage.get('orca:pushServiceNotificationsEnabled')).toBe('true') + if (entry === 'settings') { + expect(renderer!.root.findByType('Switch').props).toMatchObject({ + value: true, + disabled: false + }) + } + if (entry === 'onboarding') { + expect(mocks.replace).toHaveBeenCalledExactlyOnceWith('/h/host') + } +} +beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + mocks.storage.clear() + resetPushRegistrationForTests() + vi.mocked(getDevicePushToken).mockResolvedValue(token) + stopSync = startPushTokenSync() +}) +afterEach(async () => { + await act(async () => renderer?.unmount()) + renderer = undefined + stopSync() + resetPushRegistrationForTests() + vi.useRealTimers() +}) + +it.each(['true', 'false'])( + 'requires consent before registering a legacy %s user', + async (legacy) => { + mocks.storage.set('orca:pushNotificationsEnabled', legacy) + const client = await connectedHost() + await expect(shouldPresentNotificationOptIn()).resolves.toBe(true) + await drain() + expect(getDevicePushToken).not.toHaveBeenCalled() + expect(client.sendRequest).not.toHaveBeenCalled() + await choose('onboarding') + await drain() + await expect(shouldPresentNotificationOptIn()).resolves.toBe(false) + expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'notifications.registerPush' + ]) + } +) + +it('remembers Not now without registering and does not ask again', async () => { + mocks.storage.set('orca:pushNotificationsEnabled', 'true') + const client = await connectedHost() + await act(async () => { + renderer = create(createElement(MobileOnboardingScreen)) + }) + await act(async () => { + renderer!.root.findByType('Page').props.onNotificationChoice('skip') + }) + await drain() + await expect(shouldPresentNotificationOptIn()).resolves.toBe(false) + expect(mocks.storage.get('orca:pushServiceNotificationsEnabled')).toBe('false') + expect(getDevicePushToken).not.toHaveBeenCalled() + expect( + client.sendRequest.mock.calls.some(([method]) => method === 'notifications.registerPush') + ).toBe(false) +}) + +it.each(['settings', 'onboarding'])( + '%s schedules exactly one registration with token sync running', + async (entry) => { + const client = await connectedHost() + await choose(entry) + await drain() + expectChoiceComplete(entry) + expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'notifications.registerPush' + ]) + expect(records().registeredHostIds).toEqual(['host']) + } +) + +it.each(['settings', 'onboarding'])( + '%s finishes local consent while native token acquisition is pending', + async (entry) => { + const client = await connectedHost() + const pending = deferred() + vi.mocked(getDevicePushToken).mockReturnValue(pending.promise) + await choose(entry) + await drain() + expectChoiceComplete(entry) + expect(getDevicePushToken).toHaveBeenCalledOnce() + expect(client.sendRequest).not.toHaveBeenCalled() + pending.resolve(token) + await drain() + expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'notifications.registerPush' + ]) + } +) + +it.each(['settings', 'onboarding'])( + '%s finishes local consent while registration RPC is pending', + async (entry) => { + const client = await connectedHost() + const pending = deferred() + client.sendRequest.mockImplementationOnce(() => pending.promise) + await choose(entry) + await drain() + expectChoiceComplete(entry) + expect(client.sendRequest).toHaveBeenCalledOnce() + expect(records().registeredHostIds).toEqual([]) + pending.resolve({ ok: true, result: { registered: true } }) + await drain() + expect(records().registeredHostIds).toEqual(['host']) + expect(client.sendRequest).toHaveBeenCalledOnce() + } +) + +it('waits for durable local records and schedules one unregister without waiting for its RPC', async () => { + const client = await connectedHost() + await setRemotePushEnabled(true) + await drain() + client.sendRequest.mockClear() + const write = deferred() + vi.mocked(AsyncStorage.setItem) + .mockImplementationOnce(async (key, value) => { + mocks.storage.set(key, value) + }) + .mockImplementationOnce(async (key, value) => { + await write.promise + mocks.storage.set(key, value) + }) + const rpc = deferred() + client.sendRequest.mockImplementationOnce(() => rpc.promise) + const completed = vi.fn() + const disable = setRemotePushEnabled(false).then(completed) + await drain() + expect(completed).not.toHaveBeenCalled() + expect(client.sendRequest).not.toHaveBeenCalled() + write.resolve() + await disable + await drain() + expect(completed).toHaveBeenCalledOnce() + expect(records().pendingUnregisterHostIds).toEqual(['host']) + expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'notifications.unregisterPush' + ]) + rpc.resolve({ ok: true }) + await drain() + expect(records()).toEqual({ registeredHostIds: [], pendingUnregisterHostIds: [] }) + expect(client.sendRequest).toHaveBeenCalledOnce() +}) + +it('exposes a failed consent write without scheduling or changing durable consent', async () => { + const client = await connectedHost() + vi.mocked(AsyncStorage.setItem).mockRejectedValueOnce(new Error('consent write failed')) + await expect(setRemotePushEnabled(true)).rejects.toThrow('consent write failed') + await drain() + expect(mocks.storage.has('orca:pushServiceNotificationsEnabled')).toBe(false) + expect(client.sendRequest).not.toHaveBeenCalled() +}) + +it('exposes a failed records write and still schedules exactly one cleanup', async () => { + const client = await connectedHost() + await setRemotePushEnabled(true) + await drain() + client.sendRequest.mockClear() + vi.mocked(AsyncStorage.setItem) + .mockImplementationOnce(async (key, value) => { + mocks.storage.set(key, value) + }) + .mockRejectedValueOnce(new Error('records write failed')) + await expect(setRemotePushEnabled(false)).rejects.toThrow('records write failed') + await drain() + expect(mocks.storage.get('orca:pushServiceNotificationsEnabled')).toBe('false') + expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'notifications.unregisterPush' + ]) + expect(records()).toEqual({ registeredHostIds: [], pendingUnregisterHostIds: [] }) +}) diff --git a/mobile/src/notifications/notification-delivery-ordering.test.ts b/mobile/src/notifications/notification-delivery-ordering.test.ts deleted file mode 100644 index 68d64d7b3de..00000000000 --- a/mobile/src/notifications/notification-delivery-ordering.test.ts +++ /dev/null @@ -1,248 +0,0 @@ -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() -let getItemImpl: (key: string) => Promise = 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 { - 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((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((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(() => {}) - - 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') - }) -}) diff --git a/mobile/src/notifications/notification-delivery-preferences.test.ts b/mobile/src/notifications/notification-delivery-preferences.test.ts new file mode 100644 index 00000000000..1e3e4b79113 --- /dev/null +++ b/mobile/src/notifications/notification-delivery-preferences.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { AppState } from 'react-native' +import { + DEFAULT_NOTIFICATION_DELIVERY, + loadNotificationDeliveryPreferences, + notificationPreferencesFilter, + saveNotificationDeliveryPreferences +} from './notification-delivery-preferences' +import { + setNotificationViewingWorkspace, + shouldSuppressNotificationWhileViewing +} from './notification-viewing-policy' + +const storage = new Map() +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: vi.fn(async (key: string) => storage.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => { + storage.set(key, value) + }) + } +})) +vi.mock('react-native', () => ({ AppState: { currentState: 'background' } })) +beforeEach(() => { + storage.clear() + setNotificationViewingWorkspace(null) + AppState.currentState = 'background' +}) + +it('persists only phone-specific delivery preferences', async () => { + expect(await loadNotificationDeliveryPreferences()).toEqual(DEFAULT_NOTIFICATION_DELIVERY) + const value = { + ...DEFAULT_NOTIFICATION_DELIVERY, + onlyWhenDesktopAway: false, + sound: false + } + await saveNotificationDeliveryPreferences(value) + expect(await loadNotificationDeliveryPreferences()).toEqual(value) + expect(notificationPreferencesFilter(value)).toEqual({ + onlyWhenDesktopAway: false, + sound: false + }) +}) + +it('ignores unrelated stored preferences', async () => { + storage.set( + 'orca:notificationDeliveryPreferences', + JSON.stringify({ + onlyWhenDesktopAway: false, + sound: false, + suppressWhileViewing: false, + unrelatedSetting: false + }) + ) + expect(await loadNotificationDeliveryPreferences()).toEqual({ + onlyWhenDesktopAway: false, + sound: false, + suppressWhileViewing: false + }) + expect(notificationPreferencesFilter(await loadNotificationDeliveryPreferences())).toEqual({ + onlyWhenDesktopAway: false, + sound: false + }) +}) + +it('suppresses only the workspace being viewed on this phone, and never while backgrounded', async () => { + const event = { source: 'terminal-bell', worktreeId: 'folder-id' } + setNotificationViewingWorkspace({ hostId: 'ssh-host', worktreeId: 'folder-id' }) + AppState.currentState = 'active' + expect(await shouldSuppressNotificationWhileViewing(event, 'ssh-host', true)).toBe(true) + expect(await shouldSuppressNotificationWhileViewing(event, 'another-host', true)).toBe(false) + expect( + await shouldSuppressNotificationWhileViewing( + { ...event, worktreeId: 'other' }, + 'ssh-host', + true + ) + ).toBe(false) + AppState.currentState = 'background' + expect(await shouldSuppressNotificationWhileViewing(event, 'ssh-host', true)).toBe(false) +}) + +it('recovers defaults from malformed stored preferences', async () => { + storage.set('orca:notificationDeliveryPreferences', '{broken') + expect(await loadNotificationDeliveryPreferences()).toEqual(DEFAULT_NOTIFICATION_DELIVERY) +}) diff --git a/mobile/src/notifications/notification-delivery-preferences.ts b/mobile/src/notifications/notification-delivery-preferences.ts new file mode 100644 index 00000000000..7388fba77db --- /dev/null +++ b/mobile/src/notifications/notification-delivery-preferences.ts @@ -0,0 +1,49 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import type { MobilePushFilter } from '../../../src/shared/mobile-push-contract' + +const KEY = 'orca:notificationDeliveryPreferences' +export type NotificationDeliveryPreferences = { + onlyWhenDesktopAway: boolean + sound: boolean + suppressWhileViewing: boolean +} + +export const DEFAULT_NOTIFICATION_DELIVERY: NotificationDeliveryPreferences = { + onlyWhenDesktopAway: true, + sound: true, + suppressWhileViewing: true +} + +export async function loadNotificationDeliveryPreferences(): Promise { + try { + const raw = await AsyncStorage.getItem(KEY) + if (!raw) { + return { ...DEFAULT_NOTIFICATION_DELIVERY } + } + const stored = JSON.parse(raw) as Record + const result = { ...DEFAULT_NOTIFICATION_DELIVERY } + for (const key of Object.keys(result) as (keyof NotificationDeliveryPreferences)[]) { + if (typeof stored?.[key] === 'boolean') { + result[key] = stored[key] + } + } + return result + } catch { + return { ...DEFAULT_NOTIFICATION_DELIVERY } + } +} + +export async function saveNotificationDeliveryPreferences( + value: NotificationDeliveryPreferences +): Promise { + await AsyncStorage.setItem(KEY, JSON.stringify(value)) +} + +export function notificationPreferencesFilter( + value: NotificationDeliveryPreferences +): MobilePushFilter { + return { + onlyWhenDesktopAway: value.onlyWhenDesktopAway, + sound: value.sound + } +} diff --git a/mobile/src/notifications/notification-opt-in-gate.test.ts b/mobile/src/notifications/notification-opt-in-gate.test.ts index ded3d7eca39..8b99423dfb4 100644 --- a/mobile/src/notifications/notification-opt-in-gate.test.ts +++ b/mobile/src/notifications/notification-opt-in-gate.test.ts @@ -1,92 +1,24 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - readPushNotificationsPreference, - savePushNotificationsEnabled -} from '../storage/preferences' -import { getNotificationPermissionState } from './mobile-notifications' +import { describe, expect, it, vi } from 'vitest' +import { readPushNotificationsPreference } from '../storage/preferences' import { shouldPresentNotificationOptIn } from './notification-opt-in-gate' vi.mock('../storage/preferences', () => ({ - readPushNotificationsPreference: vi.fn(), - savePushNotificationsEnabled: vi.fn() -})) - -vi.mock('./mobile-notifications', () => ({ - getNotificationPermissionState: vi.fn() + readPushNotificationsPreference: vi.fn() })) describe('notification opt-in gate', () => { - beforeEach(() => { - vi.mocked(readPushNotificationsPreference).mockReset() - vi.mocked(savePushNotificationsEnabled).mockReset() - vi.mocked(getNotificationPermissionState).mockReset() - }) - - it('presents only when the local preference and system decision are both unset', async () => { + it('asks for push-service consent when no choice is saved, regardless of OS permission', async () => { vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true }) - vi.mocked(getNotificationPermissionState).mockResolvedValue({ - granted: false, - status: 'undetermined', - canAskAgain: true, - authorizationReflectsUserChoice: false - }) - await expect(shouldPresentNotificationOptIn()).resolves.toBe(true) - expect(savePushNotificationsEnabled).not.toHaveBeenCalled() }) - it.each([true, false])('preserves an existing %s mobile preference', async (value) => { + it.each([true, false])('does not ask again after choosing %s', async (value) => { vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value, loaded: true }) - await expect(shouldPresentNotificationOptIn()).resolves.toBe(false) - expect(getNotificationPermissionState).not.toHaveBeenCalled() }) - it('adopts existing system authorization without prompting', async () => { - vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true }) - vi.mocked(getNotificationPermissionState).mockResolvedValue({ - granted: true, - status: 'granted', - canAskAgain: true, - authorizationReflectsUserChoice: true - }) - - await expect(shouldPresentNotificationOptIn()).resolves.toBe(false) - expect(savePushNotificationsEnabled).toHaveBeenCalledWith(true) - }) - - it('still presents when a pre-Android 13 default grant is not an opt-in decision', async () => { - vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true }) - vi.mocked(getNotificationPermissionState).mockResolvedValue({ - granted: true, - status: 'granted', - canAskAgain: true, - authorizationReflectsUserChoice: false - }) - - await expect(shouldPresentNotificationOptIn()).resolves.toBe(true) - expect(savePushNotificationsEnabled).not.toHaveBeenCalled() - }) - - it('skips the gate when iOS has already denied permission', async () => { - vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true }) - vi.mocked(getNotificationPermissionState).mockResolvedValue({ - granted: false, - status: 'denied', - canAskAgain: false, - authorizationReflectsUserChoice: false - }) - - await expect(shouldPresentNotificationOptIn()).resolves.toBe(false) - expect(savePushNotificationsEnabled).toHaveBeenCalledWith(false) - }) - - it('does not block startup when storage or permission checks fail', async () => { + it('does not prompt when the saved choice cannot be read', async () => { vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: false }) await expect(shouldPresentNotificationOptIn()).resolves.toBe(false) - - vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true }) - vi.mocked(getNotificationPermissionState).mockRejectedValue(new Error('unavailable')) - await expect(shouldPresentNotificationOptIn()).resolves.toBe(false) }) }) diff --git a/mobile/src/notifications/notification-opt-in-gate.ts b/mobile/src/notifications/notification-opt-in-gate.ts index a909fad16d7..6ffcb662d7f 100644 --- a/mobile/src/notifications/notification-opt-in-gate.ts +++ b/mobile/src/notifications/notification-opt-in-gate.ts @@ -1,36 +1,6 @@ -import { - readPushNotificationsPreference, - savePushNotificationsEnabled -} from '../storage/preferences' -import { getNotificationPermissionState } from './mobile-notifications' +import { readPushNotificationsPreference } from '../storage/preferences' export async function shouldPresentNotificationOptIn(): Promise { const preference = await readPushNotificationsPreference() - if (!preference.loaded || preference.value !== null) { - return false - } - - try { - const permission = await getNotificationPermissionState() - if (permission.granted) { - if (!permission.authorizationReflectsUserChoice) { - return true - } - // Why: an already-authorized device should inherit the useful default - // without seeing an onboarding decision it has effectively made. - await savePushNotificationsEnabled(true) - return false - } - if (permission.status === 'denied' || !permission.canAskAgain) { - // Why: iOS cannot show its authorization prompt again, so a blocking - // onboarding screen would be a dead end; Settings remains the recovery. - await savePushNotificationsEnabled(false) - return false - } - return permission.status === 'undetermined' - } catch { - // Why: permission or persistence failures must not trap startup behind a - // decision screen whose result cannot be applied reliably. - return false - } + return preference.loaded && preference.value === null } diff --git a/mobile/src/notifications/notification-reconnect-catchup.ts b/mobile/src/notifications/notification-reconnect-catchup.ts deleted file mode 100644 index de05ed69505..00000000000 --- a/mobile/src/notifications/notification-reconnect-catchup.ts +++ /dev/null @@ -1,412 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage' - -// Why: the reconnect catch-up watermark + dedup helpers for #8129, extracted -// from mobile-notifications.ts so that file stays under its max-lines budget. -// The highest desktop notification seq this device has delivered is persisted -// per-host so it survives app restarts. On reconnect we send it to -// notifications.getMissedSince as the catch-up watermark — the desktop then -// returns only notifications dispatched after it, so we never re-push a -// 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). -// 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 watermarkStorageKey(hostId: string): string { - return WATERMARK_STORAGE_KEY_PREFIX + encodeURIComponent(hostId) -} - -// 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 { - try { - 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 { - // 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 clearWatermark(hostId: string): Promise { - // 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 { - try { - 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 - // re-fetch — and, once the in-memory seen-set is gone, re-show — an already - // delivered notification. That's the accepted at-least-once trade-off; - // within a live session the in-memory watermark is authoritative, so only - // post-restart reconnects are affected. - } -} - -// Why: bounded in-memory dedup window for notificationIds/dismiss ids observed -// on the current connection. The desktop already dedupes by seq on replay, but -// a socket that flickers background→foreground→background can deliver an event -// on the live stream and again in a replay; the seen-set guarantees each -// notificationId maps to at most one local push for the connection lifetime. -// Bounded so a long-lived session can't grow without limit — a 2x superset of -// the desktop's 256-entry replay buffer and the 256 scheduled-notification cap. -const RECENTLY_SEEN_CAP = 512 - -export function createSeenNotificationGuard(): { - has: (id: string) => boolean - add: (id: string) => void - clear: () => void -} { - const seen = new Set() - return { - has(id: string): boolean { - return seen.has(id) - }, - add(id: string): void { - seen.add(id) - if (seen.size > RECENTLY_SEEN_CAP) { - // Why: insertion order; the oldest entries are first. Drop one to stay - // bounded without disturbing the more-recently-relevant keys. - const first = seen.values().next().value - if (first !== undefined) { - 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 - // Highest seq known delivered CONTIGUOUSLY, frozen here while a catch-up is - // outstanding; null when none has failed. See quarantineCatchUpWatermark. - catchUpQuarantineSeq: number | null - seen: ReturnType - // 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 | null - // Tail of the per-host delivery chain; see enqueueHostDelivery. - deliveryTail: Promise - // notificationIds with a show queued or in flight on that chain; see - // shouldQueueShowForNotificationId. - queuedShowIds: Set -} - -const sessionsByHost = new Map() - -export function getHostNotificationSession(hostId: string): HostNotificationSession { - let session = sessionsByHost.get(hostId) - if (!session) { - session = { - lastDeliveredSeq: 0, - lastDeliveredEpoch: null, - catchUpQuarantineSeq: null, - seen: createSeenNotificationGuard(), - connectedBefore: false, - hadStoredWatermark: false, - watermarkSeeded: null, - deliveryTail: Promise.resolve(), - queuedShowIds: new Set() - } - 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 -): Promise { - 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() -} - -/** - * Freeze the catch-up watermark at the last seq known delivered contiguously, - * after a catch-up that did not complete. - * - * Why: live delivery advances lastDeliveredSeq unconditionally, so an abandoned - * catch-up otherwise lets the NEXT one ask from above the range it gave up on — - * the desktop cuts by seq, so those notifications are never replayed and are - * gone. Lowest wins: an earlier failure's gap is still open. - */ -export function quarantineCatchUpWatermark( - session: HostNotificationSession, - hostId: string, - contiguousSeq: number -): void { - session.catchUpQuarantineSeq = - session.catchUpQuarantineSeq == null - ? contiguousSeq - : Math.min(session.catchUpQuarantineSeq, contiguousSeq) - // Why re-persist: a live event delivered while the catch-up was still in flight - // already stored a seq above the gap. Clamping only later writes would leave that - // value on disk, so a restart still resumes past the abandoned range. - void saveWatermark(hostId, { - seq: catchUpWatermarkSeq(session), - epoch: session.lastDeliveredEpoch - }) -} - -/** Lift the quarantine once a catch-up completes, persisting what it held back. */ -export function resolveCatchUpQuarantine(session: HostNotificationSession, hostId: string): void { - if (session.catchUpQuarantineSeq == null) { - return - } - session.catchUpQuarantineSeq = null - void saveWatermark(hostId, { - seq: session.lastDeliveredSeq, - epoch: session.lastDeliveredEpoch - }) -} - -/** - * The seq a catch-up may ask from and the highest seq safe to persist — the live - * watermark, clamped to any open gap. - */ -export function catchUpWatermarkSeq(session: HostNotificationSession): number { - return session.catchUpQuarantineSeq == null - ? session.lastDeliveredSeq - : Math.min(session.catchUpQuarantineSeq, session.lastDeliveredSeq) -} - -// 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() - // The quarantined gap indexed the dead counter; the watermark it guarded is gone too. - session.catchUpQuarantineSeq = null - 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, ms: number): Promise { - return new Promise((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 -// as a duplicate, while a replay re-returning the SAME id+seq already delivered -// live is suppressed. Replay events always carry a seq (the desktop assigns -// one), so the guard is effective on the reconnect path. -export function seenKeyForEvent(event: { - notificationId?: string - notificationSeq?: number -}): string | null { - const id = event.notificationId - if (id != null && event.notificationSeq != null) { - return `id:${id}#${event.notificationSeq}` - } - if (id != null) { - return `id:${id}` - } - if (event.notificationSeq != null) { - return `seq:${event.notificationSeq}` - } - return null -} diff --git a/mobile/src/notifications/notification-reconnect-teardown.test.ts b/mobile/src/notifications/notification-reconnect-teardown.test.ts deleted file mode 100644 index a5e7433bf0f..00000000000 --- a/mobile/src/notifications/notification-reconnect-teardown.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -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() -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 { - 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') - }) -}) diff --git a/mobile/src/notifications/notification-routing.test.ts b/mobile/src/notifications/notification-routing.test.ts index 779aa2425e5..9b682bc2cb7 100644 --- a/mobile/src/notifications/notification-routing.test.ts +++ b/mobile/src/notifications/notification-routing.test.ts @@ -1,29 +1,10 @@ import { describe, expect, it } from 'vitest' import { - buildLocalNotificationData, getNotificationNavigationTarget, notificationCredentialRecoveryRoute } from './notification-routing' describe('notification routing', () => { - it('includes the host id in locally scheduled notification data', () => { - expect( - buildLocalNotificationData( - { - source: 'agent-task-complete', - worktreeId: 'repo::/Users/me/orca/workspaces/feature', - notificationId: 'agent:one' - }, - 'host-1' - ) - ).toEqual({ - source: 'agent-task-complete', - hostId: 'host-1', - worktreeId: 'repo::/Users/me/orca/workspaces/feature', - notificationId: 'agent:one' - }) - }) - // Identities stay raw: the target is dispatched as navigator params, not a URL. it('routes notification taps to the worktree terminal screen', () => { expect( @@ -88,3 +69,11 @@ describe('notification routing', () => { expect(notificationCredentialRecoveryRoute(target!)).toBeNull() }) }) + +it('preserves the originating pane in the workspace route', () => { + const paneKey = 'tab-b:11111111-1111-4111-8111-111111111111' + expect( + getNotificationNavigationTarget({ hostId: 'host', worktreeId: 'folder:/work', paneKey }) + ?.sessionTarget?.params + ).toEqual({ hostId: 'host', worktreeId: 'folder:/work', paneKey }) +}) diff --git a/mobile/src/notifications/notification-routing.ts b/mobile/src/notifications/notification-routing.ts index 5f81fb3567d..d1a8b6eebf3 100644 --- a/mobile/src/notifications/notification-routing.ts +++ b/mobile/src/notifications/notification-routing.ts @@ -2,21 +2,6 @@ import type { HostStackRouteTarget } from '../navigation/host-stack-navigation' import { mobileSessionRouteTarget } from '../session/mobile-session-route' import type { HostCredentialStatus } from '../transport/types' -export type DesktopNotificationSource = 'agent-task-complete' | 'terminal-bell' | 'test' - -export type DesktopNotificationEvent = { - source: DesktopNotificationSource - worktreeId?: string - notificationId?: string -} - -export type LocalNotificationData = { - source: DesktopNotificationSource - hostId: string - worktreeId?: string - notificationId?: string -} - export type NotificationNavigationOptions = { knownHostIds?: ReadonlySet credentialStatusByHostId?: ReadonlyMap @@ -26,23 +11,6 @@ function readNonEmptyString(value: unknown): string | null { return typeof value === 'string' && value.trim().length > 0 ? value : null } -export function buildLocalNotificationData( - event: DesktopNotificationEvent, - hostId: string -): LocalNotificationData { - const data: LocalNotificationData = { - source: event.source, - hostId - } - if (event.worktreeId) { - data.worktreeId = event.worktreeId - } - if (event.notificationId) { - data.notificationId = event.notificationId - } - return data -} - /** Where a tap should land. `sessionTarget` is null for a host-only notification, whose * `/h/` push is shallow enough to need no host-stack coordination. */ export type NotificationNavigationTarget = Readonly<{ @@ -81,7 +49,13 @@ export function getNotificationNavigationTarget( const credentialStatus = options.credentialStatusByHostId?.get(hostId) return { hostId, - sessionTarget: worktreeId ? mobileSessionRouteTarget({ hostId, worktreeId }) : null, + sessionTarget: worktreeId + ? mobileSessionRouteTarget({ + hostId, + worktreeId, + paneKey: readNonEmptyString(record.paneKey) ?? undefined + }) + : null, ...(credentialStatus === 'missing' ? { credentialRecovery: 're-pair' as const } : credentialStatus === 'temporarily-unavailable' diff --git a/mobile/src/notifications/notification-viewing-policy.ts b/mobile/src/notifications/notification-viewing-policy.ts new file mode 100644 index 00000000000..ea770cf3e74 --- /dev/null +++ b/mobile/src/notifications/notification-viewing-policy.ts @@ -0,0 +1,19 @@ +import { AppState } from 'react-native' + +let viewing: { hostId: string; worktreeId: string } | null = null +export function setNotificationViewingWorkspace(value: typeof viewing): void { + viewing = value +} + +export function shouldSuppressNotificationWhileViewing( + event: { worktreeId?: string }, + hostId: string, + suppressWhileViewing: boolean +): boolean { + return ( + suppressWhileViewing && + AppState.currentState === 'active' && + viewing?.hostId === hostId && + viewing.worktreeId === event.worktreeId + ) +} diff --git a/mobile/src/notifications/notification-watermark-seed-race.test.ts b/mobile/src/notifications/notification-watermark-seed-race.test.ts deleted file mode 100644 index 742f0711982..00000000000 --- a/mobile/src/notifications/notification-watermark-seed-race.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -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() -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((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 { - 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) - }) -}) diff --git a/mobile/src/notifications/push-background-dismissal.test.ts b/mobile/src/notifications/push-background-dismissal.test.ts new file mode 100644 index 00000000000..40e535e2fcf --- /dev/null +++ b/mobile/src/notifications/push-background-dismissal.test.ts @@ -0,0 +1,72 @@ +import { expect, it, vi } from 'vitest' +const state = vi.hoisted(() => ({ task: null as null | ((input: unknown) => Promise) })) +vi.mock('expo-task-manager', () => ({ + defineTask: (_name: string, task: typeof state.task) => { + state.task = task + }, + isAvailableAsync: async () => true +})) +vi.mock('expo-notifications', () => ({ + registerTaskAsync: vi.fn(), + getPresentedNotificationsAsync: vi.fn(async () => []), + dismissNotificationAsync: vi.fn() +})) +vi.mock('./push-tray-dismissal', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + dismissPresentedPushNotification: vi.fn(actual.dismissPresentedPushNotification) + } +}) +import * as Notifications from 'expo-notifications' +import { dismissPresentedPushNotification } from './push-tray-dismissal' +import { registerPushDismissalTask } from './push-background-dismissal' + +it('handles native background JSON and scopes dismissal to the originating host', async () => { + await registerPushDismissalTask() + await state.task!({ + data: { + data: { + dataString: JSON.stringify({ + kind: 'dismiss', + hostFingerprint: 'host-a', + notificationId: 'same-id' + }) + } + } + }) + expect(dismissPresentedPushNotification).toHaveBeenCalledWith( + 'same-id', + 'host-a', + expect.objectContaining({ kind: 'dismiss' }) + ) +}) + +it('does not turn ordinary alerts into dismissals', async () => { + vi.mocked(dismissPresentedPushNotification).mockClear() + await state.task!({ + data: { data: { orca: { hostFingerprint: 'host-a', notificationId: 'same-id' } } } + }) + expect(dismissPresentedPushNotification).not.toHaveBeenCalled() +}) + +it('an ID-only background dismissal preserves versioned tray alerts', async () => { + const base = { hostFingerprint: 'host-a', notificationId: 'same-id' } + vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([ + { request: { identifier: 'legacy', content: { data: base } } }, + { + request: { + identifier: 'versioned', + content: { + data: { + ...base, + notificationEpoch: 'epoch', + notificationSeq: 3 + } + } + } + } + ] as never) + await state.task!({ data: { data: { ...base, kind: 'dismiss' } } }) + expect(Notifications.dismissNotificationAsync).toHaveBeenCalledExactlyOnceWith('legacy') +}) diff --git a/mobile/src/notifications/push-background-dismissal.ts b/mobile/src/notifications/push-background-dismissal.ts new file mode 100644 index 00000000000..4689d58ef58 --- /dev/null +++ b/mobile/src/notifications/push-background-dismissal.ts @@ -0,0 +1,41 @@ +import { wasPushDismissed } from './push-dismissal-watermarks' +import * as TaskManager from 'expo-task-manager' +import * as Notifications from 'expo-notifications' +import { readOrcaPushPayload } from './push-payload' +import { dismissPresentedPushNotification } from './push-tray-dismissal' + +const TASK_NAME = 'orca-push-dismissal' + +TaskManager.defineTask( + TASK_NAME, + async ({ data, error }) => { + if (error || !data || 'actionIdentifier' in data) { + return + } + let raw: unknown = data.data + if (typeof data.data.dataString === 'string') { + try { + raw = JSON.parse(data.data.dataString) + } catch { + return + } + } + const payload = readOrcaPushPayload(raw) + if ( + payload?.notificationId && + (payload.kind === 'dismiss' || (await wasPushDismissed(payload))) + ) { + await dismissPresentedPushNotification( + payload.notificationId, + payload.hostFingerprint, + payload + ) + } + } +) + +export async function registerPushDismissalTask(): Promise { + if (await TaskManager.isAvailableAsync()) { + await Notifications.registerTaskAsync(TASK_NAME) + } +} diff --git a/mobile/src/notifications/push-dismissal-native-races.test.ts b/mobile/src/notifications/push-dismissal-native-races.test.ts new file mode 100644 index 00000000000..35a51748906 --- /dev/null +++ b/mobile/src/notifications/push-dismissal-native-races.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import AsyncStorage from '@react-native-async-storage/async-storage' +import { nativePushDismissal } from './native-push-dismissal' +import { rememberPushDismissal, wasPushDismissed } from './push-dismissal-watermarks' +import { foregroundNotificationBehavior } from './push-receive' +import { loadNotificationDeliveryPreferences } from './notification-delivery-preferences' + +const memory = vi.hoisted(() => new Map()) +const nativeLedger = vi.hoisted(() => new Map()) +vi.mock('./native-push-dismissal', () => ({ + nativePushDismissal: { + remember: vi.fn(async (payload) => { + const key = JSON.stringify([ + payload.hostFingerprint, + payload.notificationEpoch, + payload.notificationId + ]) + nativeLedger.set(key, Math.max(nativeLedger.get(key) ?? 0, payload.notificationSeq)) + }), + wasDismissed: vi.fn( + async (payload) => + (nativeLedger.get( + JSON.stringify([ + payload.hostFingerprint, + payload.notificationEpoch, + payload.notificationId + ]) + ) ?? -1) >= payload.notificationSeq + ) + } +})) +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: vi.fn(async (key: string) => memory.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => { + memory.set(key, value) + }) + } +})) +vi.mock('expo-notifications', () => ({ getPresentedNotificationsAsync: async () => [] })) +vi.mock('../transport/host-store', () => ({ loadHostCatalog: async () => [{ id: 'host' }] })) +vi.mock('./push-host-fingerprint', () => ({ resolveHostIdForFingerprint: () => 'host' })) +vi.mock('../storage/preferences', () => ({ + loadPushNotificationsEnabled: async () => true +})) +vi.mock('./notification-viewing-policy', () => ({ + shouldSuppressNotificationWhileViewing: () => false +})) +vi.mock('./notification-delivery-preferences', () => ({ + loadNotificationDeliveryPreferences: vi.fn(async () => ({ sound: true })) +})) + +const payload = { + hostFingerprint: 'abcdefghijklmnop', + notificationEpoch: 'epoch', + notificationId: 'note', + notificationSeq: 20 +} +const fence = { ...payload, notificationSeq: 21 } + +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(AsyncStorage.getItem).mockImplementation(async (key) => memory.get(key) ?? null) + memory.clear() + nativeLedger.clear() +}) + +it('uses only native storage for iOS dismissal reads and writes', async () => { + await rememberPushDismissal(fence) + expect(await wasPushDismissed(payload)).toBe(true) + expect(await wasPushDismissed({ ...payload, notificationSeq: 22 })).toBe(false) + expect(await wasPushDismissed({ ...payload, notificationEpoch: 'new-epoch' })).toBe(false) + expect(AsyncStorage.getItem).not.toHaveBeenCalled() + expect(AsyncStorage.setItem).not.toHaveBeenCalled() +}) + +it('rechecks a negative native snapshot overtaken by a dismissal', async () => { + let finish!: () => void + vi.mocked(nativePushDismissal!.wasDismissed).mockImplementationOnce(async () => { + await new Promise((resolve) => { + finish = resolve + }) + return false + }) + const pending = wasPushDismissed(payload) + await vi.waitFor(() => expect(finish).toBeDefined()) + await rememberPushDismissal(fence) + finish() + expect(await pending).toBe(true) + expect(nativePushDismissal!.wasDismissed).toHaveBeenCalledTimes(2) +}) + +it('surfaces native write failures without switching storage or poisoning later operations', async () => { + vi.mocked(nativePushDismissal!.remember).mockRejectedValueOnce(new Error('native failure')) + await expect(rememberPushDismissal(fence)).rejects.toThrow('native failure') + expect(AsyncStorage.setItem).not.toHaveBeenCalled() + await rememberPushDismissal(fence) + expect(await wasPushDismissed(payload)).toBe(true) +}) + +it('suppresses presentation when dismissal completes during the handler sound read', async () => { + let finish!: () => void + vi.mocked(loadNotificationDeliveryPreferences).mockImplementationOnce(async () => { + await new Promise((resolve) => { + finish = resolve + }) + return { sound: true } as Awaited> + }) + const pending = foregroundNotificationBehavior({ + request: { + identifier: 'foreground-alert', + trigger: null, + content: { title: null, subtitle: null, body: null, sound: null, data: { orca: payload } } + } + }) + await vi.waitFor(() => expect(finish).toBeDefined()) + await foregroundNotificationBehavior({ + request: { content: { data: { orca: { ...fence, kind: 'dismiss' } } } } + }) + expect(await wasPushDismissed(payload)).toBe(true) + finish() + expect(await pending).toEqual({ + shouldShowBanner: false, + shouldShowList: false, + shouldPlaySound: false, + shouldSetBadge: false + }) +}) diff --git a/mobile/src/notifications/push-dismissal-reconciliation.test.ts b/mobile/src/notifications/push-dismissal-reconciliation.test.ts new file mode 100644 index 00000000000..f1e1d34c606 --- /dev/null +++ b/mobile/src/notifications/push-dismissal-reconciliation.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import * as Notifications from 'expo-notifications' +import { loadHostCatalog } from '../transport/host-store' +import { deriveHostFingerprint } from './push-host-fingerprint' +import { requestNotificationCatchup } from './push-dismissal-reconciliation' +vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn() })) +vi.mock('expo-notifications', () => ({ + getPresentedNotificationsAsync: vi.fn(), + dismissNotificationAsync: vi.fn() +})) +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { getItem: async () => null, setItem: async () => {} } +})) +const publicKeyB64 = Buffer.alloc(32, 1).toString('base64') +const hostFingerprint = deriveHostFingerprint(publicKeyB64) +const id = { + notificationId: 'old-alert', + notificationEpoch: 'previous-host-process', + notificationSeq: 12 +} +function presented(identifier: string, overrides = {}) { + return { request: { identifier, content: { data: { hostFingerprint, ...id, ...overrides } } } } +} +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(loadHostCatalog).mockResolvedValue([{ id: 'host-a', publicKeyB64 }] as never) + vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([ + presented('old'), + presented('new', { notificationSeq: 14 }), + presented('other', { hostFingerprint: 'other-host' }) + ] as never) + vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined) +}) +it('clears a confirmed prior-epoch alert even with empty replay and preserves newer and other-host entries', async () => { + const sendRequest = vi.fn(async () => ({ + ok: true, + result: { notifications: [], epoch: 'new-process', dismissedPushes: [id] } + })) + await requestNotificationCatchup({ sendRequest } as never, 'host-a', () => false) + expect(sendRequest).toHaveBeenCalledWith('notifications.getMissedSince', { + lastSeenSeq: Number.MAX_SAFE_INTEGER, + deliveredPushes: [id, { ...id, notificationSeq: 14 }] + }) + expect(Notifications.dismissNotificationAsync).toHaveBeenCalledExactlyOnceWith('old') +}) +it('keeps alerts when an old host omits reconciliation or the request fails', async () => { + for (const response of [{ ok: true, result: { notifications: [] } }, { ok: false }]) { + await requestNotificationCatchup( + { sendRequest: async () => response } as never, + 'host-a', + () => false + ) + } + expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalled() +}) +it('ignores unrequested identities and a response arriving after disconnect', async () => { + let disposed = false + const sendRequest = vi.fn(async () => ({ + ok: true, + result: { + dismissedPushes: [ + { ...id, notificationSeq: 99 }, + { ...id, notificationEpoch: 'different-epoch' }, + { ...id, notificationId: 'different-alert' } + ] + } + })) + await requestNotificationCatchup({ sendRequest } as never, 'host-a', () => disposed) + sendRequest.mockImplementationOnce(async () => { + disposed = true + return { ok: true, result: { dismissedPushes: [id] } } + }) + await requestNotificationCatchup({ sendRequest } as never, 'host-a', () => disposed) + expect(sendRequest).toHaveBeenCalledTimes(2) + expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalled() +}) + +it('skips the replay RPC when the tray has no alerts for this host', async () => { + vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([ + presented('other', { hostFingerprint: 'other-host' }) + ] as never) + const sendRequest = vi.fn() + await requestNotificationCatchup({ sendRequest } as never, 'host-a', () => false) + expect(sendRequest).not.toHaveBeenCalled() +}) + +it('pages individual tray identities without requesting historical alerts', async () => { + const all = Array.from({ length: 288 }, (_, index) => ({ + hostFingerprint, + notificationId: `paged-${index}`, + notificationEpoch: 'previous-host-process', + notificationSeq: index + })) + vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue( + all.map((payload) => presented(payload.notificationId, payload)) as never + ) + const sendRequest = vi.fn(async (_method: string, params: { deliveredPushes?: typeof all }) => ({ + ok: true, + result: { notifications: [], dismissedPushes: params.deliveredPushes ?? [] } + })) + await requestNotificationCatchup({ sendRequest } as never, 'host-a', () => false) + expect(sendRequest).toHaveBeenCalledTimes(2) + expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ + lastSeenSeq: Number.MAX_SAFE_INTEGER + }) + expect(sendRequest.mock.calls[0]?.[1].deliveredPushes).toHaveLength(256) + expect(sendRequest.mock.calls[1]?.[1]).toMatchObject({ + lastSeenSeq: Number.MAX_SAFE_INTEGER, + deliveredPushes: all + .slice(256) + .map(({ notificationId, notificationEpoch, notificationSeq }) => ({ + notificationId, + notificationEpoch, + notificationSeq + })) + }) + expect(vi.mocked(Notifications.dismissNotificationAsync)).toHaveBeenCalledTimes(288) +}) + +it.each(['failure', 'disconnect'])( + 'stops after a second-page %s without removing unconfirmed alerts', + async (outcome) => { + vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue( + Array.from({ length: 513 }, (_, index) => + presented(`paged-${index}`, { notificationId: `paged-${index}`, notificationSeq: index }) + ) as never + ) + let disposed = false + let pages = 0 + const sendRequest = vi.fn( + async (_method: string, params: { deliveredPushes: (typeof id)[] }) => { + pages++ + disposed = pages === 2 && outcome === 'disconnect' + return { + ok: !(pages === 2 && outcome === 'failure'), + result: { dismissedPushes: params.deliveredPushes } + } + } + ) + await requestNotificationCatchup({ sendRequest } as never, 'host-a', () => disposed) + expect(sendRequest).toHaveBeenCalledTimes(2) + expect(Notifications.dismissNotificationAsync).toHaveBeenCalledTimes(256) + expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalledWith('paged-256') + } +) diff --git a/mobile/src/notifications/push-dismissal-reconciliation.ts b/mobile/src/notifications/push-dismissal-reconciliation.ts new file mode 100644 index 00000000000..4c39ccfeb0f --- /dev/null +++ b/mobile/src/notifications/push-dismissal-reconciliation.ts @@ -0,0 +1,81 @@ +import * as Notifications from 'expo-notifications' +import type { RpcClient } from '../transport/rpc-client' +import { loadHostCatalog } from '../transport/host-store' +import { resolveHostIdForFingerprint } from './push-host-fingerprint' +import { readNativeNotificationData } from './native-notification-data' +import { readOrcaPushPayload, type OrcaPushPayload } from './push-payload' +import { dismissRememberedPushNotifications } from './push-tray-dismissal' +import { rememberPushDismissal } from './push-dismissal-watermarks' +import { + readPushNotificationIdentity, + type PushNotificationIdentity +} from './push-notification-identity' + +const key = (item: PushNotificationIdentity) => + JSON.stringify([item.notificationId, item.notificationEpoch, item.notificationSeq]) +async function readDelivered(hostId: string): Promise> { + const selected = new Map() + try { + const [presented, hosts] = await Promise.all([ + Notifications.getPresentedNotificationsAsync(), + loadHostCatalog() + ]) + for (const notification of presented) { + const payload = readOrcaPushPayload(readNativeNotificationData(notification.request)) + if (!payload || resolveHostIdForFingerprint(payload.hostFingerprint, hosts) !== hostId) { + continue + } + const identity = readPushNotificationIdentity(payload) + if (identity && selected.size < 2048) { + selected.set(key(identity), payload) + } + if (selected.size === 2048) { + break + } + } + } catch { + // Tray inspection is best-effort; failure leaves OS banners for later reconciliation. + } + return selected +} + +export async function requestNotificationCatchup( + client: Pick, + hostId: string, + isDisposed: () => boolean +): Promise { + const entries = [...(await readDelivered(hostId)).entries()] + for (let offset = 0; offset < entries.length && !isDisposed(); offset += 256) { + const requested = new Map(entries.slice(offset, offset + 256)) + const reply = await client.sendRequest('notifications.getMissedSince', { + // Reconcile the tray without requesting historical alerts. + lastSeenSeq: Number.MAX_SAFE_INTEGER, + deliveredPushes: [...requested.values()].map((payload) => + readPushNotificationIdentity(payload)! + ) + }) + if (!reply.ok || isDisposed()) { + return + } + const result = reply.result as { dismissedPushes?: unknown } | undefined + if (!Array.isArray(result?.dismissedPushes)) { + continue + } + const confirmed: OrcaPushPayload[] = [] + for (const raw of result.dismissedPushes.slice(0, 256)) { + if (isDisposed()) { + break + } + const id = readPushNotificationIdentity(raw) + const payload = id ? requested.get(key(id)) : undefined + if (payload && id) { + await rememberPushDismissal(payload) + confirmed.push(payload) + requested.delete(key(id)) + } + } + if (confirmed.length && !isDisposed()) { + await dismissRememberedPushNotifications(confirmed[0]!.hostFingerprint, confirmed) + } + } +} diff --git a/mobile/src/notifications/push-dismissal-watermarks.test.ts b/mobile/src/notifications/push-dismissal-watermarks.test.ts new file mode 100644 index 00000000000..cef0cf3404e --- /dev/null +++ b/mobile/src/notifications/push-dismissal-watermarks.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import AsyncStorage from '@react-native-async-storage/async-storage' +const storage = vi.hoisted(() => new Map()) +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: vi.fn(async (key: string) => storage.get(key) ?? null), + setItem: async (key: string, value: string) => { + storage.set(key, value) + } + } +})) +import { rememberPushDismissal, wasPushDismissed } from './push-dismissal-watermarks' +const payload = { + hostFingerprint: 'host-a', + notificationEpoch: 'epoch-a', + notificationId: 'note', + notificationSeq: 2 +} +beforeEach(() => { + storage.clear() + vi.mocked(AsyncStorage.getItem) + .mockReset() + .mockImplementation(async (key) => storage.get(key) ?? null) + vi.useRealTimers() +}) + +it('persists dismissal through restart while preserving newer alerts and other hosts or epochs', async () => { + await rememberPushDismissal(payload) + vi.resetModules() + const restarted = await import('./push-dismissal-watermarks') + expect(await restarted.wasPushDismissed({ ...payload, notificationSeq: 1 })).toBe(true) + expect(await restarted.wasPushDismissed({ ...payload, notificationSeq: 3 })).toBe(false) + expect(await restarted.wasPushDismissed({ ...payload, hostFingerprint: 'host-b' })).toBe(false) + expect(await restarted.wasPushDismissed({ ...payload, notificationEpoch: 'epoch-b' })).toBe(false) +}) + +it('serializes concurrent dismissals and never lowers a watermark', async () => { + await Promise.all([ + rememberPushDismissal({ ...payload, notificationSeq: 5 }), + rememberPushDismissal(payload), + rememberPushDismissal({ ...payload, notificationId: 'other' }) + ]) + expect(await wasPushDismissed({ ...payload, notificationSeq: 5 })).toBe(true) + expect(await wasPushDismissed({ ...payload, notificationId: 'other' })).toBe(true) +}) + +it('expires retained metadata and ignores unversioned dismissals', async () => { + vi.useFakeTimers() + await rememberPushDismissal(payload) + vi.setSystemTime(Date.now() + 24 * 60 * 60 * 1000) + expect(await wasPushDismissed(payload)).toBe(false) + await rememberPushDismissal({ ...payload, notificationEpoch: undefined }) + expect(await wasPushDismissed(payload)).toBe(false) +}) + +it('joins an overtaking JavaScript write before retrying a delayed negative snapshot', async () => { + let finish!: () => void + vi.mocked(AsyncStorage.getItem).mockImplementationOnce(async (key) => { + const snapshot = storage.get(key) ?? null + await new Promise((resolve) => { + finish = resolve + }) + return snapshot + }) + const pending = wasPushDismissed(payload) + await vi.waitFor(() => expect(finish).toBeDefined()) + await rememberPushDismissal(payload) + finish() + expect(await pending).toBe(true) + expect(AsyncStorage.getItem).toHaveBeenCalledTimes(3) + expect(await wasPushDismissed({ ...payload, notificationSeq: 3 })).toBe(false) +}) + +it.each([1, 3])('retains live dismissals beyond 512 entries across %i hosts', async (hosts) => { + for (let index = 0; index < 520; index++) { + await rememberPushDismissal({ + ...payload, + hostFingerprint: `host-${index % hosts}`, + notificationId: `note-${index}` + }) + } + vi.resetModules() + const restarted = await import('./push-dismissal-watermarks') + for (const index of [0, 1, 519]) { + const alert = { + ...payload, + hostFingerprint: `host-${index % hosts}`, + notificationId: `note-${index}` + } + expect(await restarted.wasPushDismissed(alert)).toBe(true) + expect(await restarted.wasPushDismissed({ ...alert, notificationSeq: 3 })).toBe(false) + } +}) diff --git a/mobile/src/notifications/push-dismissal-watermarks.ts b/mobile/src/notifications/push-dismissal-watermarks.ts new file mode 100644 index 00000000000..8202af054d7 --- /dev/null +++ b/mobile/src/notifications/push-dismissal-watermarks.ts @@ -0,0 +1,104 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import type { OrcaPushPayload } from './push-payload' +import { nativePushDismissal } from './native-push-dismissal' + +const STORAGE_KEY = 'orca:pushDismissalWatermarks:v1' +// Keep every live fence: count-based eviction lets delayed alerts reappear. +const RETENTION_MS = 24 * 60 * 60 * 1000 + +type Entry = { key: string; seq: number; expiresAt: number } +let writes: Promise = Promise.resolve() + +function queueDismissalOperation(operation: () => Promise): Promise { + const pending = writes.then(operation) + writes = pending.then( + () => {}, + () => {} + ) + return pending +} + +function eventKey(payload: OrcaPushPayload): string | null { + if ( + !payload.notificationId || + !payload.notificationEpoch || + !Number.isSafeInteger(payload.notificationSeq) || + payload.notificationSeq! < 0 + ) { + return null + } + return JSON.stringify([ + payload.hostFingerprint, + payload.notificationEpoch, + payload.notificationId + ]) +} + +async function readEntries(): Promise { + try { + const raw: unknown = JSON.parse((await AsyncStorage.getItem(STORAGE_KEY)) ?? '[]') + if (!Array.isArray(raw)) { + return [] + } + return raw.filter( + (entry): entry is Entry => + entry !== null && + typeof entry === 'object' && + typeof entry.key === 'string' && + Number.isSafeInteger(entry.seq) && + entry.seq >= 0 && + Number.isFinite(entry.expiresAt) && + entry.expiresAt > Date.now() + ) + } catch { + return [] + } +} + +export async function rememberPushDismissal(payload: OrcaPushPayload): Promise { + const key = eventKey(payload) + if (!key) { + return + } + return queueDismissalOperation(async () => { + if (nativePushDismissal) { + await nativePushDismissal.remember(payload) + return + } + const entries = await readEntries() + const previous = entries.find((entry) => entry.key === key) + const entry = { + key, + seq: Math.max(previous?.seq ?? 0, payload.notificationSeq!), + expiresAt: Date.now() + RETENTION_MS + } + await AsyncStorage.setItem( + STORAGE_KEY, + JSON.stringify([...entries.filter((item) => item.key !== key), entry]) + ) + }) +} + +async function readDismissal(payload: OrcaPushPayload, key: string): Promise { + if (nativePushDismissal) { + return nativePushDismissal.wasDismissed(payload) + } + return (await readEntries()).some( + (entry) => entry.key === key && entry.seq >= payload.notificationSeq! + ) +} + +export async function wasPushDismissed(payload: OrcaPushPayload): Promise { + const key = eventKey(payload) + if (!key) { + return false + } + const precedingWrites = writes + await precedingWrites + const dismissed = await readDismissal(payload, key) + if (dismissed || writes === precedingWrites) { + return dismissed + } + // An overtaking write invalidates a negative snapshot; one queued read cannot be overtaken again. + return queueDismissalOperation(() => readDismissal(payload, key)) +} diff --git a/mobile/src/notifications/push-host-fingerprint.test.ts b/mobile/src/notifications/push-host-fingerprint.test.ts new file mode 100644 index 00000000000..2fc5b44dba1 --- /dev/null +++ b/mobile/src/notifications/push-host-fingerprint.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { sha256 } from '@noble/hashes/sha256' +import { deriveHostFingerprint, resolveHostIdForFingerprint } from './push-host-fingerprint' + +// Why Buffer here: it computes the same value through a completely different +// base64 path than the module's btoa/replace, so the vector is a real cross-check +// of the derivation the desktop and gateway independently perform. +function expectedFingerprint(publicKey: Uint8Array): string { + return Buffer.from(sha256(publicKey)).toString('base64url').slice(0, 16) +} + +const publicKey = Uint8Array.from({ length: 32 }, (_, index) => index) +const publicKeyB64 = Buffer.from(publicKey).toString('base64') + +describe('deriveHostFingerprint', () => { + it('matches base64url(sha256(publicKey)) truncated to 16 chars', () => { + const fingerprint = deriveHostFingerprint(publicKeyB64) + + expect(fingerprint).toBe(expectedFingerprint(publicKey)) + expect(fingerprint).toHaveLength(16) + }) + + it('produces url-safe characters only, so a fingerprint survives a JSON payload', () => { + // 0xff bytes are what push '+' and '/' into a standard base64 digest. + const dense = new Uint8Array(32).fill(0xff) + const fingerprint = deriveHostFingerprint(Buffer.from(dense).toString('base64')) + + expect(fingerprint).toBe(expectedFingerprint(dense)) + expect(fingerprint).toMatch(/^[A-Za-z0-9_-]{16}$/) + }) + + it.each([ + ['a key of the wrong length', Buffer.from(new Uint8Array(16)).toString('base64')], + ['text that is not base64 at all', '!!!not base64!!!'], + ['an empty key', ''] + ])('returns null for %s', (_label, value) => { + expect(deriveHostFingerprint(value)).toBeNull() + }) +}) + +describe('resolveHostIdForFingerprint', () => { + const other = Uint8Array.from({ length: 32 }, (_, index) => index + 1) + const hosts = [ + { id: 'host-corrupt', publicKeyB64: 'not-a-key' }, + { id: 'host-other', publicKeyB64: Buffer.from(other).toString('base64') }, + { id: 'host-1', publicKeyB64 } + ] + + it('maps a push fingerprint back to the paired host id', () => { + expect(resolveHostIdForFingerprint(expectedFingerprint(publicKey), hosts)).toBe('host-1') + }) + + it('returns null for a fingerprint no paired host derives', () => { + expect(resolveHostIdForFingerprint('0123456789abcdef', hosts)).toBeNull() + }) + + it('rejects a fingerprint of the wrong length before hashing anything', () => { + expect( + resolveHostIdForFingerprint(expectedFingerprint(publicKey).slice(0, 8), hosts) + ).toBeNull() + }) +}) diff --git a/mobile/src/notifications/push-host-fingerprint.ts b/mobile/src/notifications/push-host-fingerprint.ts new file mode 100644 index 00000000000..3aa8b739fba --- /dev/null +++ b/mobile/src/notifications/push-host-fingerprint.ts @@ -0,0 +1,58 @@ +import { sha256 } from '@noble/hashes/sha256' + +// Why: a push arrives from the gateway, so it can only name the host by something +// both sides derive independently — base64url(sha256(hostPublicKey)) truncated to +// 16 chars, identical to deriveRelayHostId in +// src/main/runtime/relay/relay-http-client.ts. The phone maps it back to its own +// hostId by re-deriving over each stored host's publicKeyB64. +// +// Base64 is inlined rather than imported (same call as mobile-relay-credential-hash.ts): +// the only shared encoders live in modules that drag in tweetnacl, expo-crypto, or +// the host store, none of which a pure derivation should need. + +const HOST_FINGERPRINT_LENGTH = 16 + +function decodeBase64(value: string): Uint8Array | null { + try { + const binary = atob(value) + const bytes = new Uint8Array(binary.length) + for (let index = 0; index < binary.length; index++) { + bytes[index] = binary.charCodeAt(index) + } + return bytes + } catch { + return null + } +} + +function encodeBase64Url(bytes: Uint8Array): string { + let binary = '' + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +/** Null when the stored key is unreadable, so a corrupt host entry can't shadow a real match. */ +export function deriveHostFingerprint(publicKeyB64: string): string | null { + const publicKey = decodeBase64(publicKeyB64) + if (!publicKey || publicKey.length !== 32) { + return null + } + return encodeBase64Url(sha256(publicKey)).slice(0, HOST_FINGERPRINT_LENGTH) +} + +export function resolveHostIdForFingerprint( + fingerprint: string, + hosts: readonly { readonly id: string; readonly publicKeyB64: string }[] +): string | null { + if (fingerprint.length !== HOST_FINGERPRINT_LENGTH) { + return null + } + for (const host of hosts) { + if (deriveHostFingerprint(host.publicKeyB64) === fingerprint) { + return host.id + } + } + return null +} diff --git a/mobile/src/notifications/push-notification-identity.test.ts b/mobile/src/notifications/push-notification-identity.test.ts new file mode 100644 index 00000000000..df3dfd9d152 --- /dev/null +++ b/mobile/src/notifications/push-notification-identity.test.ts @@ -0,0 +1,25 @@ +import { expect, it } from 'vitest' +import { + readPushNotificationIdentity, + type PushNotificationIdentity +} from './push-notification-identity' + +it('reads a bounded individual notification identity', () => { + const identity: PushNotificationIdentity = { + notificationId: 'agent:one', + notificationEpoch: 'epoch-1', + notificationSeq: 7 + } + expect(readPushNotificationIdentity(identity)).toEqual(identity) +}) + +it('rejects incomplete or non-integral notification identities', () => { + expect(readPushNotificationIdentity({ notificationId: 'agent:one' })).toBeNull() + expect( + readPushNotificationIdentity({ + notificationId: 'agent:one', + notificationEpoch: 'epoch-1', + notificationSeq: 1.5 + }) + ).toBeNull() +}) diff --git a/mobile/src/notifications/push-notification-identity.ts b/mobile/src/notifications/push-notification-identity.ts new file mode 100644 index 00000000000..b8e9e73a34b --- /dev/null +++ b/mobile/src/notifications/push-notification-identity.ts @@ -0,0 +1,26 @@ +export type PushNotificationIdentity = { + notificationId: string + notificationEpoch: string + notificationSeq: number +} + +export function readPushNotificationIdentity(value: unknown): PushNotificationIdentity | null { + if (!value || typeof value !== 'object') { + return null + } + const item = value as PushNotificationIdentity + return typeof item.notificationId === 'string' && + item.notificationId.length > 0 && + item.notificationId.length <= 2048 && + typeof item.notificationEpoch === 'string' && + item.notificationEpoch.length > 0 && + item.notificationEpoch.length <= 128 && + Number.isSafeInteger(item.notificationSeq) && + item.notificationSeq >= 0 + ? { + notificationId: item.notificationId, + notificationEpoch: item.notificationEpoch, + notificationSeq: item.notificationSeq + } + : null +} diff --git a/mobile/src/notifications/push-payload.ts b/mobile/src/notifications/push-payload.ts new file mode 100644 index 00000000000..bcdbf0f6073 --- /dev/null +++ b/mobile/src/notifications/push-payload.ts @@ -0,0 +1,43 @@ +// Why two shapes: APNs nests Orca's fields under `orca` beside `aps`, while FCM +// carries them flat in `data` as strings. Both reach JS as the notification's +// `content.data`, so the reader accepts either and coerces the numeric fields. +export type OrcaPushPayload = { + readonly kind?: 'alert' | 'dismiss' + readonly hostFingerprint: string + readonly notificationId?: string + readonly notificationSeq?: number + readonly notificationEpoch?: string + readonly paneKey?: string + readonly worktreeId?: string +} + +function readString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +function readSeq(value: unknown): number | undefined { + const raw = typeof value === 'number' ? value : Number(readString(value)) + return Number.isFinite(raw) ? raw : undefined +} + +export function readOrcaPushPayload(data: unknown): OrcaPushPayload | null { + if (!data || typeof data !== 'object') { + return null + } + const nested = (data as { orca?: unknown }).orca + const record = (nested && typeof nested === 'object' ? nested : data) as Record + // The fingerprint is what makes this a gateway push; locally scheduled data never has one. + const hostFingerprint = readString(record.hostFingerprint) + if (!hostFingerprint) { + return null + } + return { + hostFingerprint, + ...(record.kind === 'dismiss' || record.kind === 'alert' ? { kind: record.kind } : {}), + notificationId: readString(record.notificationId), + notificationSeq: readSeq(record.notificationSeq), + notificationEpoch: readString(record.notificationEpoch), + paneKey: readString(record.paneKey), + worktreeId: readString(record.worktreeId) + } +} diff --git a/mobile/src/notifications/push-preference-update.test.ts b/mobile/src/notifications/push-preference-update.test.ts new file mode 100644 index 00000000000..11f137fb38f --- /dev/null +++ b/mobile/src/notifications/push-preference-update.test.ts @@ -0,0 +1,86 @@ +vi.mock('./desktop-notification-channel', () => ({ + ensureDesktopNotificationChannel: vi.fn(async () => {}) +})) +import { AppState } from 'react-native' +import { beforeEach, expect, it, vi } from 'vitest' +import { + attachPushRegistration, + resetPushRegistrationForTests, + setNotificationDeliveryPreferences, + NOTIFICATIONS_REMOTE_PUSH_CAPABILITY +} from './push-registration' +import { DEFAULT_NOTIFICATION_DELIVERY } from './notification-delivery-preferences' + +const storage = new Map() +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: vi.fn(async (key: string) => storage.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => { + storage.set(key, value) + }) + } +})) +vi.mock('react-native', () => ({ + AppState: { currentState: 'active', addEventListener: vi.fn(() => ({ remove: vi.fn() })) } +})) + +vi.mock('./push-token', () => ({ + getDevicePushToken: vi.fn(async () => ({ + platform: 'ios', + token: 'a'.repeat(64), + apnsEnvironment: 'sandbox' + })), + addPushTokenListener: vi.fn() +})) + +beforeEach(() => { + AppState.currentState = 'active' + resetPushRegistrationForTests() + storage.clear() + storage.set('orca:pushServiceNotificationsEnabled', 'true') +}) + +it('replaces an in-flight registration with the latest away and sound preferences', async () => { + const calls: { method: string; params: unknown }[] = [] + let finishFirst: ((value: unknown) => void) | undefined + const client = { + sendRequest: vi.fn(async (method: string, params?: unknown) => { + calls.push({ method, params }) + if (method === 'status.get') { + return { ok: true, result: { capabilities: [NOTIFICATIONS_REMOTE_PUSH_CAPABILITY] } } + } + if (method === 'notifications.registerPush') { + if (!finishFirst) { + return new Promise((resolve) => { + finishFirst = resolve + }) + } + return { ok: true, result: { registered: true, registrationId: 'new' } } + } + return { ok: true, result: { unregistered: true } } + }) + } + const detach = attachPushRegistration('host', client as never) + await vi.waitFor(() => expect(finishFirst).toBeDefined()) + const update = setNotificationDeliveryPreferences({ + ...DEFAULT_NOTIFICATION_DELIVERY, + onlyWhenDesktopAway: false, + sound: false + }) + finishFirst!({ ok: true, result: { registered: true, registrationId: 'old' } }) + await update + await vi.waitFor(() => + expect( + calls.filter((call) => call.method === 'notifications.registerPush').length + ).toBeGreaterThan(1) + ) + const latest = calls.findLast((call) => call.method === 'notifications.registerPush') + expect(latest?.params).toMatchObject({ + filter: { + onlyWhenDesktopAway: false, + sound: false + } + }) + expect(calls.some((call) => call.method === 'notifications.unregisterPush')).toBe(true) + detach() +}) diff --git a/mobile/src/notifications/push-receive.test.ts b/mobile/src/notifications/push-receive.test.ts new file mode 100644 index 00000000000..8bcaef51e83 --- /dev/null +++ b/mobile/src/notifications/push-receive.test.ts @@ -0,0 +1,274 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { AppState } from 'react-native' +import { setNotificationViewingWorkspace } from './notification-viewing-policy' +vi.mock('./push-tray-dismissal', () => ({ dismissPresentedPushNotification: vi.fn() })) +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { sha256 } from '@noble/hashes/sha256' +import { loadHostCatalog } from '../transport/host-store' +import type { HostCatalogEntry } from '../transport/types' +import { getNotificationNavigationTarget } from './notification-routing' +import { + foregroundNotificationBehavior, + canPresentForegroundPush, + isRemotePushTrigger, + pushNotificationRouteData, + resetForegroundPushClaimsForTests +} from './push-receive' + +async function shouldSuppressForegroundPush(data: unknown): Promise { + return !(await foregroundNotificationBehavior({ request: { content: { data } } })) + .shouldShowBanner +} + +vi.mock('react-native', () => ({ AppState: { currentState: 'background' } })) +vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn() })) +const storage = vi.hoisted(() => new Map()) +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: vi.fn(async (key: string) => storage.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => storage.set(key, value)) + } +})) + +const publicKeyB64 = Buffer.alloc(32, 1).toString('base64') +const hostFingerprint = Buffer.from(sha256(Buffer.alloc(32, 1))) + .toString('base64url') + .slice(0, 16) +const hosts = [{ id: 'host-1', publicKeyB64 }] as unknown as HostCatalogEntry[] +const otherPublicKeyB64 = Buffer.alloc(32, 2).toString('base64') +const otherHostFingerprint = Buffer.from(sha256(Buffer.alloc(32, 2))) + .toString('base64url') + .slice(0, 16) + +function apnsData(orca: Record): unknown { + return { aps: { alert: { title: 'Orca', body: 'Agent needs input' } }, orca } +} +function fcmData(orca: Record): unknown { + return Object.fromEntries(Object.entries(orca).map(([key, value]) => [key, String(value)])) +} + +beforeEach(() => { + vi.clearAllMocks() + AppState.currentState = 'background' + setNotificationViewingWorkspace(null) + storage.clear() + storage.set('orca:pushServiceNotificationsEnabled', 'true') + resetForegroundPushClaimsForTests() + vi.mocked(loadHostCatalog).mockResolvedValue([ + ...hosts, + { id: 'host-2', publicKeyB64: otherPublicKeyB64 } + ] as unknown as HostCatalogEntry[]) +}) + +describe('shouldSuppressForegroundPush', () => { + const push = () => + apnsData({ + hostFingerprint, + notificationId: 'agent:one', + notificationSeq: 7, + notificationEpoch: 'epoch-1' + }) + + it('allows one eligible native push and suppresses an in-process duplicate', async () => { + await expect(shouldSuppressForegroundPush(push())).resolves.toBe(false) + await expect(shouldSuppressForegroundPush(push())).resolves.toBe(true) + }) + + it('reads flat FCM fields and allows the first native push', async () => { + await expect( + shouldSuppressForegroundPush( + fcmData({ + hostFingerprint, + notificationId: 'agent:one', + notificationSeq: 8, + notificationEpoch: 'epoch-1' + }) + ) + ).resolves.toBe(false) + }) + + it('deduplicates ID-less bells by host, epoch, and valid sequence', async () => { + const bell = (overrides: Record = {}) => + apnsData({ + hostFingerprint, + source: 'terminal-bell', + notificationSeq: 4, + notificationEpoch: 'epoch-1', + ...overrides + }) + await expect(shouldSuppressForegroundPush(bell())).resolves.toBe(false) + await expect(shouldSuppressForegroundPush(bell())).resolves.toBe(true) + await expect(shouldSuppressForegroundPush(bell({ notificationSeq: 5 }))).resolves.toBe(false) + await expect( + shouldSuppressForegroundPush(bell({ notificationEpoch: 'epoch-2' })) + ).resolves.toBe(false) + await expect( + shouldSuppressForegroundPush(bell({ hostFingerprint: otherHostFingerprint })) + ).resolves.toBe(false) + }) + + it('does not claim invalid sequence values as duplicate identities', async () => { + const invalid = apnsData({ + hostFingerprint, + source: 'plugin', + notificationSeq: 1.5, + notificationEpoch: 'epoch-1' + }) + await expect(shouldSuppressForegroundPush(invalid)).resolves.toBe(false) + await expect(shouldSuppressForegroundPush(invalid)).resolves.toBe(false) + }) + + it('suppresses pushes for an unpaired host', async () => { + vi.mocked(loadHostCatalog).mockResolvedValue([]) + await expect( + shouldSuppressForegroundPush(apnsData({ hostFingerprint, notificationSeq: 1 })) + ).resolves.toBe(true) + }) + + it('suppresses a push after a matching persisted dismissal', async () => { + const { rememberPushDismissal } = await import('./push-dismissal-watermarks') + const payload = { + hostFingerprint, + notificationId: 'dismissed', + notificationSeq: 2, + notificationEpoch: 'epoch-1' + } + await rememberPushDismissal(payload) + await expect(shouldSuppressForegroundPush(apnsData(payload))).resolves.toBe(true) + }) + + it('fails closed for recognized pushes when suppression checks throw', async () => { + const dismissals = await import('./push-dismissal-watermarks') + const dismissalSpy = vi + .spyOn(dismissals, 'wasPushDismissed') + .mockRejectedValueOnce(new Error('dismissal read failed')) + await expect( + foregroundNotificationBehavior({ request: { content: { data: push() } } }) + ).resolves.toMatchObject({ shouldShowBanner: false, shouldShowList: false }) + dismissalSpy.mockRestore() + }) + + it('keeps unrelated notifications visible when suppression checks throw', async () => { + const dismissals = await import('./push-dismissal-watermarks') + const dismissalSpy = vi + .spyOn(dismissals, 'wasPushDismissed') + .mockRejectedValue(new Error('dismissal read failed')) + await expect( + foregroundNotificationBehavior({ + request: { content: { data: { title: 'Other app notification' } } } + }) + ).resolves.toMatchObject({ shouldShowBanner: true, shouldShowList: true }) + dismissalSpy.mockRestore() + }) +}) + +describe('pushNotificationRouteData', () => { + it('routes a tap by mapping the fingerprint to the paired host id', () => { + const data = pushNotificationRouteData( + apnsData({ hostFingerprint, worktreeId: 'repo::/feature', source: 'agent-task-complete' }), + hosts + ) + expect(getNotificationNavigationTarget(data, { knownHostIds: new Set(['host-1']) })).toEqual({ + hostId: 'host-1', + sessionTarget: { + name: '[hostId]/session/[worktreeId]', + params: { hostId: 'host-1', worktreeId: 'repo::/feature' } + } + }) + }) + + it('maps a push without a worktree to the host screen', () => { + const data = pushNotificationRouteData( + fcmData({ hostFingerprint, source: 'terminal-bell' }), + hosts + ) + expect(getNotificationNavigationTarget(data)).toEqual({ hostId: 'host-1', sessionTarget: null }) + }) + + it('keeps local data untouched and rejects an unresolvable remote fingerprint', () => { + const local = { hostId: 'host-9', source: 'agent-task-complete' } + expect(pushNotificationRouteData(local, hosts)).toBe(local) + expect( + pushNotificationRouteData( + { hostId: 'host-1', orca: { hostFingerprint: 'unknown' } }, + hosts, + true + ) + ).toBeNull() + }) + + it('recognises only provider-delivered triggers', () => { + expect(isRemotePushTrigger({ type: 'push' })).toBe(true) + expect(isRemotePushTrigger({ type: 'timeInterval' })).toBe(false) + }) +}) + +it('uses one delivery snapshot for sound and viewing even when settings change during host lookup', async () => { + AppState.currentState = 'active' + setNotificationViewingWorkspace({ hostId: 'host-1', worktreeId: 'folder' }) + storage.set( + 'orca:notificationDeliveryPreferences', + JSON.stringify({ + sound: false, + suppressWhileViewing: false + }) + ) + vi.mocked(loadHostCatalog).mockImplementationOnce(async () => { + storage.set( + 'orca:notificationDeliveryPreferences', + JSON.stringify({ + sound: true, + suppressWhileViewing: true + }) + ) + return hosts + }) + const behavior = await foregroundNotificationBehavior({ + request: { + content: { + data: apnsData({ + hostFingerprint, + worktreeId: 'folder', + notificationEpoch: 'snapshot', + notificationSeq: 1 + }) + } + } + }) + expect(behavior).toMatchObject({ shouldShowBanner: true, shouldPlaySound: false }) + expect( + vi + .mocked(AsyncStorage.getItem) + .mock.calls.filter(([key]) => key === 'orca:notificationDeliveryPreferences') + ).toHaveLength(1) +}) + +it.each(['apns', 'fcm'])( + 'routes %s pane payload to the correct host, workspace and pane', + (provider) => { + const paneKey = 'tab-b:11111111-1111-4111-8111-111111111111' + const payload = { hostFingerprint, worktreeId: 'folder:/work', paneKey } + const data = provider === 'apns' ? { orca: payload } : payload + const routed = pushNotificationRouteData(data, [{ id: 'host', publicKeyB64 }], true) + expect(getNotificationNavigationTarget(routed)?.sessionTarget?.params).toEqual({ + hostId: 'host', + worktreeId: 'folder:/work', + paneKey + }) + } +) + +it('preflight does not consume the final presentation claim and observes later dismissals', async () => { + const payload = { + hostFingerprint, + notificationId: 'preflight', + notificationEpoch: 'epoch', + notificationSeq: 4 + } + await expect(canPresentForegroundPush(payload)).resolves.toBe(true) + await expect(shouldSuppressForegroundPush(apnsData(payload))).resolves.toBe(false) + const { rememberPushDismissal } = await import('./push-dismissal-watermarks') + await rememberPushDismissal(payload) + await expect(canPresentForegroundPush(payload)).resolves.toBe(false) + await expect(shouldSuppressForegroundPush(apnsData(payload))).resolves.toBe(true) +}) diff --git a/mobile/src/notifications/push-receive.ts b/mobile/src/notifications/push-receive.ts new file mode 100644 index 00000000000..b106ec555f2 --- /dev/null +++ b/mobile/src/notifications/push-receive.ts @@ -0,0 +1,152 @@ +import { wasPushDismissed } from './push-dismissal-watermarks' +import { dismissPresentedPushNotification } from './push-tray-dismissal' +import { shouldSuppressNotificationWhileViewing } from './notification-viewing-policy' +import { loadPushNotificationsEnabled } from '../storage/preferences' +import { loadHostCatalog } from '../transport/host-store' +import { resolveHostIdForFingerprint } from './push-host-fingerprint' +import { readOrcaPushPayload, type OrcaPushPayload } from './push-payload' +import type { Notification, NotificationBehavior } from 'expo-notifications' +import { readNativeNotificationData } from './native-notification-data' +import { loadNotificationDeliveryPreferences } from './notification-delivery-preferences' + +const RECENT_FOREGROUND_PUSH_CAP = 512 +const recentForegroundPushes = new Set() + +function claimForegroundPush(payload: OrcaPushPayload): boolean { + const seq = payload.notificationSeq + if ( + !payload.notificationEpoch || + typeof seq !== 'number' || + !Number.isSafeInteger(seq) || + seq < 0 + ) { + return true + } + const key = JSON.stringify([ + payload.hostFingerprint, + payload.notificationEpoch, + payload.notificationId ?? null, + seq + ]) + if (recentForegroundPushes.has(key)) { + return false + } + recentForegroundPushes.add(key) + if (recentForegroundPushes.size > RECENT_FOREGROUND_PUSH_CAP) { + const oldest = recentForegroundPushes.values().next().value + if (oldest !== undefined) { + recentForegroundPushes.delete(oldest) + } + } + return true +} + +export function resetForegroundPushClaimsForTests(): void { + recentForegroundPushes.clear() +} + +export async function foregroundNotificationBehavior( + notification: Pick +): Promise { + const data = readNativeNotificationData(notification.request) + const payload = readOrcaPushPayload(data) + const preferences = await loadNotificationDeliveryPreferences() + // Unrecognized notifications retain normal behavior; recognized pushes fail closed + // when consent, host, viewing, or dismissal checks cannot complete. + const ineligible = await shouldSuppressForegroundPush( + payload, + preferences.suppressWhileViewing + ).catch(() => payload !== null) + const suppressed = ineligible || (payload !== null && !claimForegroundPush(payload)) + return { + shouldShowBanner: !suppressed, + shouldShowList: !suppressed, + shouldPlaySound: !suppressed && preferences.sound, + shouldSetBadge: false + } +} + +export async function canPresentForegroundPush(payload: OrcaPushPayload): Promise { + const preferences = await loadNotificationDeliveryPreferences() + return !(await shouldSuppressForegroundPush(payload, preferences.suppressWhileViewing)) +} + +async function resolvePushHostId(payload: OrcaPushPayload): Promise { + const hosts = await loadHostCatalog().catch(() => []) + return resolveHostIdForFingerprint(payload.hostFingerprint, hosts) +} + +async function shouldSuppressForegroundPush( + payload: OrcaPushPayload | null, + suppressWhileViewing: boolean +): Promise { + if (!payload) { + return false + } + if (payload.kind === 'dismiss') { + if (payload.notificationId) { + await dismissPresentedPushNotification( + payload.notificationId, + payload.hostFingerprint, + payload + ) + } + return true + } + const hostId = await resolvePushHostId(payload) + // Why suppressed rather than shown: the only pushes that outlive their host are + // ones a gateway registration still holds after a removal whose unregister never + // reached the desktop. A banner naming a host this phone no longer has cannot be + // tapped anywhere, so it is noise the user cannot act on or turn off per-host. + if (!hostId) { + return true + } + if (!(await loadPushNotificationsEnabled())) { + return true + } + if (shouldSuppressNotificationWhileViewing(payload, hostId, suppressWhileViewing)) { + return true + } + // Keep this last: a socket/native dismissal may land during any preference or host read. + return wasPushDismissed(payload) +} + +/** Whether the OS says a notification came from a provider rather than this app. */ +export function isRemotePushTrigger(trigger: unknown): boolean { + return ( + typeof trigger === 'object' && + trigger !== null && + (trigger as { readonly type?: unknown }).type === 'push' + ) +} + +/** + * Notification data a tap can route with: the gateway names the host by fingerprint, + * so it is mapped back to this device's hostId. Locally scheduled data passes + * through untouched, which is what keeps its taps on their existing path. + * + * Why null and not the raw data when the fingerprint does not resolve: a gateway + * payload is attacker-adjacent input, and passing it on would let a stray `hostId` + * beside the `orca` block route a tap at a host the push never named. A remote + * push with no fingerprint at all is the same input minus the block, so it is + * unrouted too rather than handed to the local path as if this app scheduled it. + */ +export function pushNotificationRouteData( + data: unknown, + hosts: readonly { readonly id: string; readonly publicKeyB64: string }[], + remote = false +): unknown { + const payload = readOrcaPushPayload(data) + if (!payload) { + return remote ? null : data + } + const hostId = resolveHostIdForFingerprint(payload.hostFingerprint, hosts) + if (!hostId) { + return null + } + return { + hostId, + ...(payload.paneKey ? { paneKey: payload.paneKey } : {}), + ...(payload.worktreeId ? { worktreeId: payload.worktreeId } : {}) + } +} diff --git a/mobile/src/notifications/push-registration-cancellation.test.ts b/mobile/src/notifications/push-registration-cancellation.test.ts new file mode 100644 index 00000000000..92acc892886 --- /dev/null +++ b/mobile/src/notifications/push-registration-cancellation.test.ts @@ -0,0 +1,263 @@ +import { ensureDesktopNotificationChannel } from './desktop-notification-channel' +vi.mock('./desktop-notification-channel', () => ({ + ensureDesktopNotificationChannel: vi.fn(async () => {}) +})) +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { + attachPushRegistration, + resetPushRegistrationForTests, + setRemotePushEnabled, + startPushTokenSync, + unregisterPushForRemovedHost, + NOTIFICATIONS_REMOTE_PUSH_CAPABILITY +} from './push-registration' +import { addPushTokenListener, getDevicePushToken } from './push-token' +import type { MobilePushToken } from './push-token' + +import AsyncStorage from '@react-native-async-storage/async-storage' +import { removeHost } from '../transport/host-store' +import { removeHostAndCloseClient } from '../transport/host-removal-lifecycle' +vi.mock('../transport/host-store', () => ({ removeHost: vi.fn() })) +vi.mock('./mobile-push-lease-renewal', () => ({ startMobilePushLeaseRenewal: () => () => {} })) + +const storage = new Map() +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: async (key: string) => storage.get(key) ?? null, + setItem: vi.fn(async (key: string, value: string) => { + storage.set(key, value) + }) + } +})) +vi.mock('react-native', () => ({ AppState: { currentState: 'active' } })) +vi.mock('./push-token', () => ({ getDevicePushToken: vi.fn(), addPushTokenListener: vi.fn() })) +const token: MobilePushToken = { + platform: 'ios', + token: 'a'.repeat(64), + apnsEnvironment: 'sandbox' +} +const records = () => JSON.parse(storage.get('orca:remotePushHostRegistrations') ?? '{}') +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} +function client( + register: () => Promise = async () => ({ ok: true, result: { registered: true } }) +) { + return { + sendRequest: vi.fn(async (method: string) => { + if (method === 'status.get') { + return { ok: true, result: { capabilities: [NOTIFICATIONS_REMOTE_PUSH_CAPABILITY] } } + } + if (method === 'notifications.registerPush') { + return register() + } + return { ok: true, result: { unregistered: true } } + }) + } +} +beforeEach(() => { + vi.clearAllMocks() + resetPushRegistrationForTests() + storage.clear() + storage.set('orca:pushServiceNotificationsEnabled', 'true') + vi.mocked(getDevicePushToken).mockResolvedValue(token) + vi.mocked(addPushTokenListener).mockReturnValue(() => {}) + vi.mocked(removeHost).mockReset() +}) + +afterEach(() => vi.useRealTimers()) + +it('does not resurrect a removed host when its registration response arrives late', async () => { + const pending = deferred() + const connection = client(() => pending.promise) + attachPushRegistration('host', connection as never) + await vi.waitFor(() => + expect(connection.sendRequest).toHaveBeenCalledWith( + 'notifications.registerPush', + expect.anything(), + expect.anything() + ) + ) + const removal = unregisterPushForRemovedHost('host') + expect(connection.sendRequest.mock.calls.map(([method]) => method)).not.toContain( + 'notifications.unregisterPush' + ) + pending.resolve({ ok: true, result: { registered: true } }) + await removal + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(records().registeredHostIds).toEqual([]) + expect(records().pendingUnregisterHostIds).toEqual([]) +}) + +it('does not start registration after removal while native token lookup was pending', async () => { + const pending = deferred() + vi.mocked(getDevicePushToken).mockReturnValueOnce(pending.promise) + const connection = client() + attachPushRegistration('host', connection as never) + await vi.waitFor(() => expect(getDevicePushToken).toHaveBeenCalled()) + await unregisterPushForRemovedHost('host') + pending.resolve(token) + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(connection.sendRequest.mock.calls.map(([method]) => method)).not.toContain( + 'notifications.registerPush' + ) +}) + +it('does not register with stale consent after the user disables notifications during token lookup', async () => { + const pending = deferred() + vi.mocked(getDevicePushToken).mockReturnValueOnce(pending.promise) + const connection = client() + attachPushRegistration('host', connection as never) + await vi.waitFor(() => expect(getDevicePushToken).toHaveBeenCalled()) + const disabled = setRemotePushEnabled(false) + pending.resolve(token) + await disabled + await vi.waitFor(() => + expect(connection.sendRequest.mock.calls.map(([method]) => method)).toContain( + 'notifications.unregisterPush' + ) + ) + expect(connection.sendRequest.mock.calls.map(([method]) => method)).not.toContain( + 'notifications.registerPush' + ) +}) + +it('waits for the Android notification channel before registering a token', async () => { + const pending = deferred() + vi.mocked(ensureDesktopNotificationChannel).mockReturnValueOnce(pending.promise) + const connection = client() + attachPushRegistration('host', connection as never) + await vi.waitFor(() => expect(ensureDesktopNotificationChannel).toHaveBeenCalled()) + expect(getDevicePushToken).not.toHaveBeenCalled() + expect(connection.sendRequest.mock.calls.map(([method]) => method)).not.toContain( + 'notifications.registerPush' + ) + pending.resolve() + await vi.waitFor(() => + expect(connection.sendRequest.mock.calls.map(([method]) => method)).toContain( + 'notifications.registerPush' + ) + ) +}) + +it('completes disable while native token acquisition remains unresolved, and rejects late tokens', async () => { + vi.useFakeTimers() + storage.set( + 'orca:remotePushHostRegistrations', + JSON.stringify({ + registeredHostIds: ['host'], + pendingUnregisterHostIds: [] + }) + ) + const pending = deferred() + vi.mocked(getDevicePushToken).mockReturnValueOnce(pending.promise) + const connection = client() + const stop = startPushTokenSync() + attachPushRegistration('host', connection as never) + await vi.advanceTimersByTimeAsync(0) + expect(getDevicePushToken).toHaveBeenCalledOnce() + await setRemotePushEnabled(false) + expect(records().pendingUnregisterHostIds).toEqual(['host']) + expect(connection.sendRequest.mock.calls.map(([method]) => method)).not.toContain( + 'notifications.unregisterPush' + ) + await vi.advanceTimersByTimeAsync(2_000) + expect(storage.get('orca:pushServiceNotificationsEnabled')).toBe('false') + expect(records()).toEqual({ registeredHostIds: [], pendingUnregisterHostIds: [] }) + expect(connection.sendRequest.mock.calls.map(([method]) => method)).toContain( + 'notifications.unregisterPush' + ) + pending.resolve(token) + vi.mocked(addPushTokenListener).mock.calls[0]![0](token) + await vi.advanceTimersByTimeAsync(0) + expect(connection.sendRequest.mock.calls.map(([method]) => method)).not.toContain( + 'notifications.registerPush' + ) + stop() +}) + +it('restores registration without reconnect after metadata removal fails, retaining detach ownership', async () => { + const connection = client() + const detach = attachPushRegistration('host', connection as never) + await vi.waitFor(() => expect(records().registeredHostIds).toEqual(['host'])) + vi.mocked(removeHost).mockRejectedValueOnce(new Error('metadata failure')) + const close = vi.fn() + await expect(removeHostAndCloseClient('host', close)).rejects.toThrow('metadata failure') + expect(close).not.toHaveBeenCalled() + await vi.waitFor(() => expect(records().registeredHostIds).toEqual(['host'])) + expect(connection.sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'status.get', + 'notifications.registerPush', + 'notifications.unregisterPush', + 'status.get', + 'notifications.registerPush' + ]) + detach() + connection.sendRequest.mockClear() + await setRemotePushEnabled(true) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(connection.sendRequest).not.toHaveBeenCalled() +}) + +it('does not revive a connection detached while metadata removal was pending', async () => { + const connection = client() + const detach = attachPushRegistration('host', connection as never) + await vi.waitFor(() => expect(records().registeredHostIds).toEqual(['host'])) + const commit = deferred() + vi.mocked(removeHost).mockImplementationOnce(async () => { + await commit.promise + throw new Error('metadata failure') + }) + const removal = expect(removeHostAndCloseClient('host', vi.fn())).rejects.toThrow( + 'metadata failure' + ) + await vi.waitFor(() => expect(removeHost).toHaveBeenCalled()) + detach() + connection.sendRequest.mockClear() + commit.resolve() + await removal + await setRemotePushEnabled(true) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(connection.sendRequest).not.toHaveBeenCalled() +}) + +it('retires late registration ownership before a failed removal restores a fresh registration', async () => { + const oldRegister = deferred() + const newRegister = deferred() + const register = vi + .fn() + .mockReturnValueOnce(oldRegister.promise) + .mockReturnValue(newRegister.promise) + const connection = client(register) + attachPushRegistration('host', connection as never) + await vi.waitFor(() => expect(register).toHaveBeenCalledOnce()) + vi.mocked(removeHost).mockRejectedValueOnce(new Error('metadata failure')) + const removal = expect(removeHostAndCloseClient('host', vi.fn())).rejects.toThrow( + 'metadata failure' + ) + expect(connection.sendRequest.mock.calls.map(([method]) => method)).not.toContain( + 'notifications.unregisterPush' + ) + oldRegister.resolve({ ok: true, result: { registered: true } }) + await removal + await vi.waitFor(() => expect(register).toHaveBeenCalledTimes(2)) + expect(records().registeredHostIds).toEqual([]) + newRegister.resolve({ ok: true, result: { registered: true } }) + await vi.waitFor(() => expect(records().registeredHostIds).toEqual(['host'])) +}) + +it('still commits removal when unregister and cleanup storage fail', async () => { + const connection = client() + attachPushRegistration('host', connection as never) + await vi.waitFor(() => expect(records().registeredHostIds).toEqual(['host'])) + connection.sendRequest.mockRejectedValueOnce(new Error('socket closed')) + vi.mocked(AsyncStorage.setItem).mockRejectedValueOnce(new Error('disk full')) + const close = vi.fn() + await removeHostAndCloseClient('host', close) + expect(removeHost).toHaveBeenCalledWith('host') + expect(close).toHaveBeenCalledWith('host') +}) diff --git a/mobile/src/notifications/push-registration.test.ts b/mobile/src/notifications/push-registration.test.ts new file mode 100644 index 00000000000..975c840133c --- /dev/null +++ b/mobile/src/notifications/push-registration.test.ts @@ -0,0 +1,423 @@ +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { getItem: vi.fn(async () => null) } +})) +vi.mock('./desktop-notification-channel', () => ({ + ensureDesktopNotificationChannel: vi.fn(async () => {}) +})) +import { AppState } from 'react-native' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient, SendRequestOptions } from '../transport/rpc-client' +import type { RpcResponse } from '../transport/types' +import { + loadPushNotificationsEnabled, + loadRemotePushHostRegistrations, + savePushNotificationsEnabled, + saveRemotePushHostRegistrations, + type RemotePushHostRegistrations +} from '../storage/preferences' +import { addPushTokenListener, getDevicePushToken, type MobilePushToken } from './push-token' +import { + NOTIFICATIONS_REMOTE_PUSH_CAPABILITY, + attachPushRegistration, + resetPushRegistrationForTests, + setRemotePushEnabled, + startPushTokenSync, + unregisterPushForRemovedHost +} from './push-registration' + +vi.mock('../storage/preferences', () => ({ + loadPushNotificationsEnabled: vi.fn(), + savePushNotificationsEnabled: vi.fn(), + loadRemotePushHostRegistrations: vi.fn(), + saveRemotePushHostRegistrations: vi.fn() +})) + +vi.mock('react-native', () => ({ + AppState: { currentState: 'active', addEventListener: vi.fn(() => ({ remove: vi.fn() })) } +})) + +vi.mock('./push-token', () => ({ + getDevicePushToken: vi.fn(), + addPushTokenListener: vi.fn() +})) + +const IOS_TOKEN: MobilePushToken = { + platform: 'ios', + token: 'a'.repeat(64), + apnsEnvironment: 'production' +} + +// Every await in the module resolves immediately, so one macrotask drains the whole +// per-host reconcile chain no matter how many hops deep it happens to be. +function flush(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + +function ok(result: unknown): RpcResponse { + return { id: 'req', ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +type SentRequest = { method: string; params?: unknown; options?: SendRequestOptions } + +function makeClient(capabilities: readonly string[]): { + client: Pick + sent: SentRequest[] +} { + const sent: SentRequest[] = [] + const client = { + sendRequest: vi.fn(async (method: string, params?: unknown, options?: SendRequestOptions) => { + sent.push({ method, params, options }) + if (method === 'status.get') { + return ok({ capabilities: [...capabilities] }) + } + if (method === 'notifications.registerPush') { + return ok({ registered: true, registrationId: 'registration-1' }) + } + if (method === 'notifications.unregisterPush') { + return ok({ unregistered: true }) + } + return ok(null) + }) + } + return { client, sent } +} + +function methodsIn(sent: SentRequest[]): string[] { + return sent.map((request) => request.method) +} + +let enabled = false +let stored: RemotePushHostRegistrations + +beforeEach(() => { + vi.clearAllMocks() + AppState.currentState = 'active' + resetPushRegistrationForTests() + enabled = false + stored = { registeredHostIds: [], pendingUnregisterHostIds: [] } + + vi.mocked(loadPushNotificationsEnabled).mockImplementation(async () => enabled) + vi.mocked(savePushNotificationsEnabled).mockImplementation(async (value) => { + enabled = value + }) + vi.mocked(loadRemotePushHostRegistrations).mockImplementation(async () => stored) + vi.mocked(saveRemotePushHostRegistrations).mockImplementation(async (value) => { + stored = value + }) + vi.mocked(getDevicePushToken).mockResolvedValue(IOS_TOKEN) +}) + +describe('push registration capability gating', () => { + it('registers a connected host that advertises remote push', async () => { + const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY]) + await setRemotePushEnabled(true) + + attachPushRegistration('host-1', client) + await flush() + + const register = sent.find((request) => request.method === 'notifications.registerPush') + expect(register?.params).toEqual({ + platform: 'ios', + token: IOS_TOKEN.token, + apnsEnvironment: 'production', + filter: { onlyWhenDesktopAway: true, sound: true } + }) + expect(stored.registeredHostIds).toEqual(['host-1']) + }) + + it('never calls registerPush on a host without the capability', async () => { + const { client, sent } = makeClient(['some-other.v1']) + await setRemotePushEnabled(true) + + attachPushRegistration('host-legacy', client) + await flush() + + expect(methodsIn(sent)).toEqual(['status.get']) + expect(stored.registeredHostIds).toEqual([]) + }) + + it('reconciles disabled consent even without registration records', async () => { + const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY]) + + attachPushRegistration('host-1', client) + await flush() + + expect(methodsIn(sent)).toEqual(['status.get', 'notifications.unregisterPush']) + }) + + it('omits apnsEnvironment for an Android token', async () => { + vi.mocked(getDevicePushToken).mockResolvedValue({ platform: 'android', token: 'fcm-token' }) + const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY]) + await setRemotePushEnabled(true) + + attachPushRegistration('host-1', client) + await flush() + + const register = sent.find((request) => request.method === 'notifications.registerPush') + expect(register?.params).toMatchObject({ platform: 'android', token: 'fcm-token' }) + expect(register?.params).not.toHaveProperty('apnsEnvironment') + }) + + it('registers nothing when the device has no push token at all', async () => { + vi.mocked(getDevicePushToken).mockResolvedValue(null) + const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY]) + await setRemotePushEnabled(true) + + attachPushRegistration('host-simulator', client) + await flush() + + expect(methodsIn(sent)).toEqual(['status.get']) + }) + + it('asks only once when the host answers that it has no push capability', async () => { + const { client, sent } = makeClient(['some-other.v1']) + await setRemotePushEnabled(true) + attachPushRegistration('host-legacy', client) + await flush() + + await setRemotePushEnabled(true) + await flush() + + expect(methodsIn(sent)).toEqual(['status.get']) + }) + + it('re-probes a host whose first status.get never answered', async () => { + vi.useFakeTimers() + const sent: string[] = [] + let probeFails = true + const client = { + sendRequest: vi.fn(async (method: string) => { + sent.push(method) + if (method === 'status.get') { + if (probeFails) { + throw new Error('request timed out') + } + return ok({ capabilities: [NOTIFICATIONS_REMOTE_PUSH_CAPABILITY] }) + } + return ok({ registered: true, registrationId: 'registration-1' }) + }) + } + await setRemotePushEnabled(true) + attachPushRegistration('host-1', client) + await vi.advanceTimersByTimeAsync(0) + expect(sent).toEqual(['status.get']) + + // A failed probe retries while the same connection remains active. + probeFails = false + await vi.advanceTimersByTimeAsync(1_000) + await Promise.resolve() + await Promise.resolve() + + expect(sent).toEqual(['status.get', 'status.get', 'notifications.registerPush']) + vi.useRealTimers() + }) + + it('retries the device token on the next reconcile after the device had none', async () => { + vi.mocked(getDevicePushToken).mockResolvedValueOnce(null).mockResolvedValue(IOS_TOKEN) + const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY]) + await setRemotePushEnabled(true) + attachPushRegistration('host-1', client) + await flush() + expect(methodsIn(sent)).toEqual(['status.get']) + + // A token can be missing only for now — APNs registration still in flight. + await setRemotePushEnabled(true) + await flush() + + expect(methodsIn(sent)).toContain('notifications.registerPush') + }) +}) + +describe('push registration token changes', () => { + it('re-registers every connected host when the provider rolls the token', async () => { + let onTokenChange: ((token: MobilePushToken) => void) | null = null + vi.mocked(addPushTokenListener).mockImplementation((listener) => { + onTokenChange = listener + return () => {} + }) + const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY]) + await setRemotePushEnabled(true) + attachPushRegistration('host-1', client) + await flush() + const stop = startPushTokenSync() + + onTokenChange?.({ platform: 'ios', token: 'b'.repeat(64), apnsEnvironment: 'sandbox' }) + await flush() + + const registers = sent.filter((request) => request.method === 'notifications.registerPush') + expect(registers).toHaveLength(2) + expect(registers[1]?.params).toMatchObject({ + token: 'b'.repeat(64), + apnsEnvironment: 'sandbox' + }) + stop() + }) +}) + +describe('push unregistration', () => { + it('unregisters a connected host as soon as the switch goes off', async () => { + const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY]) + await setRemotePushEnabled(true) + attachPushRegistration('host-1', client) + await flush() + + await setRemotePushEnabled(false) + await flush() + + expect(methodsIn(sent)).toContain('notifications.unregisterPush') + expect(stored.registeredHostIds).toEqual([]) + expect(stored.pendingUnregisterHostIds).toEqual([]) + }) + + it('retries the unregister on a host that was offline when the switch went off', async () => { + const first = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY]) + await setRemotePushEnabled(true) + const detach = attachPushRegistration('host-1', first.client) + await flush() + detach() + + await setRemotePushEnabled(false) + await flush() + expect(methodsIn(first.sent)).not.toContain('notifications.unregisterPush') + expect(stored.pendingUnregisterHostIds).toEqual(['host-1']) + + // A fresh process: only the persisted intent survives the restart. + AppState.currentState = 'active' + resetPushRegistrationForTests() + const reconnected = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY]) + attachPushRegistration('host-1', reconnected.client) + await flush() + + // No probe first: a pending entry is a switch-off the user already performed, so + // it must not wait on a status.get that may never answer. + expect(methodsIn(reconnected.sent)).toEqual(['notifications.unregisterPush']) + expect(stored.pendingUnregisterHostIds).toEqual([]) + }) + + it('recovers an offline disable after its cleanup write fails and mobile restarts', async () => { + const first = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY]) + await setRemotePushEnabled(true) + const detach = attachPushRegistration('host-1', first.client) + await flush() + detach() + vi.mocked(saveRemotePushHostRegistrations).mockRejectedValueOnce(new Error('disk full')) + + await expect(setRemotePushEnabled(false)).rejects.toThrow('disk full') + expect(enabled).toBe(false) + expect(stored.pendingUnregisterHostIds).toEqual([]) + + resetPushRegistrationForTests() + const reconnected = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY]) + attachPushRegistration('host-1', reconnected.client) + await flush() + + expect(methodsIn(reconnected.sent)).toEqual(['status.get', 'notifications.unregisterPush']) + expect(stored).toEqual({ registeredHostIds: [], pendingUnregisterHostIds: [] }) + }) + + it('keeps the pending intent when the retry itself fails', async () => { + stored = { registeredHostIds: ['host-1'], pendingUnregisterHostIds: ['host-1'] } + const client = { + sendRequest: vi.fn(async (method: string) => + method === 'status.get' + ? ok({ capabilities: [NOTIFICATIONS_REMOTE_PUSH_CAPABILITY] }) + : Promise.reject(new Error('socket closed')) + ) + } + + attachPushRegistration('host-1', client) + await flush() + + expect(stored.pendingUnregisterHostIds).toEqual(['host-1']) + }) + + it('unregisters best-effort before a removed host loses its credentials', async () => { + const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY]) + await setRemotePushEnabled(true) + attachPushRegistration('host-1', client) + await flush() + + await unregisterPushForRemovedHost('host-1') + + expect(methodsIn(sent)).toContain('notifications.unregisterPush') + expect(stored.registeredHostIds).toEqual([]) + expect(stored.pendingUnregisterHostIds).toEqual([]) + }) + + it('drops a removed host that was never connected without any request', async () => { + stored = { registeredHostIds: ['host-gone'], pendingUnregisterHostIds: ['host-gone'] } + + await unregisterPushForRemovedHost('host-gone') + + expect(stored).toEqual({ registeredHostIds: [], pendingUnregisterHostIds: [] }) + }) + + it('unregisters a pending host even when its capability probe never answers', async () => { + stored = { registeredHostIds: ['host-1'], pendingUnregisterHostIds: ['host-1'] } + const sent: string[] = [] + const client = { + sendRequest: vi.fn(async (method: string) => { + sent.push(method) + if (method === 'status.get') { + throw new Error('request timed out') + } + return ok({ unregistered: true }) + }) + } + + attachPushRegistration('host-1', client) + await flush() + + // Gating this on the probe leaves the gateway pushing while the switch reads off. + expect(sent).toEqual(['notifications.unregisterPush']) + expect(stored.pendingUnregisterHostIds).toEqual([]) + }) + + it('re-arms the unregister when the switch goes off while a register is in flight', async () => { + const sent: string[] = [] + let releaseRegister: (() => void) | null = null + const client = { + sendRequest: vi.fn(async (method: string) => { + sent.push(method) + if (method === 'status.get') { + return ok({ capabilities: [NOTIFICATIONS_REMOTE_PUSH_CAPABILITY] }) + } + if (method === 'notifications.registerPush') { + await new Promise((resolve) => { + releaseRegister = resolve + }) + return ok({ registered: true, registrationId: 'registration-1' }) + } + return ok({ unregistered: true }) + }) + } + await setRemotePushEnabled(true) + attachPushRegistration('host-1', client) + await flush() + + // The sweep snapshots `registered` while this host is still only in flight. + const switchedOff = setRemotePushEnabled(false) + await flush() + releaseRegister?.() + await switchedOff + await flush() + + // Recording the late success would leave a live gateway registration behind a + // switch that reads off, with nothing pending to ever retract it. + expect(sent).toContain('notifications.unregisterPush') + expect(stored).toEqual({ registeredHostIds: [], pendingUnregisterHostIds: [] }) + }) +}) + +it('does not register or renew when a connected phone is in the background', async () => { + AppState.currentState = 'background' + const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY]) + await setRemotePushEnabled(true) + attachPushRegistration('background-phone', client) + await flush() + expect(methodsIn(sent)).not.toContain('notifications.registerPush') + AppState.currentState = 'active' + attachPushRegistration('background-phone', client) + await flush() + expect(methodsIn(sent)).toContain('notifications.registerPush') +}) diff --git a/mobile/src/notifications/push-registration.ts b/mobile/src/notifications/push-registration.ts new file mode 100644 index 00000000000..f5463714b7d --- /dev/null +++ b/mobile/src/notifications/push-registration.ts @@ -0,0 +1,328 @@ +import { ensureDesktopNotificationChannel } from './desktop-notification-channel' +import { AppState } from 'react-native' +import { startMobilePushLeaseRenewal } from './mobile-push-lease-renewal' +import { + loadNotificationDeliveryPreferences, + notificationPreferencesFilter, + saveNotificationDeliveryPreferences, + type NotificationDeliveryPreferences +} from './notification-delivery-preferences' +import type { + MobilePushFilter, + MobilePushRegisterInput, + MobilePushRegisterResult +} from '../../../src/shared/mobile-push-contract' +import { NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' +import type { RpcClient } from '../transport/rpc-client' +import { startRuntimeCapabilityProbe } from '../transport/runtime-capability-probe' +import { + loadPushNotificationsEnabled, + loadRemotePushHostRegistrations, + savePushNotificationsEnabled, + saveRemotePushHostRegistrations +} from '../storage/preferences' +import { addPushTokenListener, getDevicePushToken, type MobilePushToken } from './push-token' + +export const NOTIFICATIONS_REMOTE_PUSH_CAPABILITY = NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY + +type PushClient = Pick + +const REQUEST_TIMEOUT_MS = 5_000 +const REMOVAL_TIMEOUT_MS = 2_000 +const TOKEN_TIMEOUT_MS = 2_000 + +type HostPushState = { + connection: { client: PushClient | null } + // An unanswered probe is unknown, not unsupported. + supported: boolean | null + capabilityProbeStop: (() => void) | null + chain: Promise +} + +type RegistrationRecords = { registered: Set; pending: Set } + +const hostsById = new Map() +let registrationRecords: RegistrationRecords | null = null +let tokenPromise: Promise | null = null +// A late registration must not overwrite a newer preference or consent choice. +let consentGeneration = 0 + +function hostState(hostId: string): HostPushState { + let state = hostsById.get(hostId) + if (!state) { + state = { + connection: { client: null }, + supported: null, + capabilityProbeStop: null, + chain: Promise.resolve() + } + hostsById.set(hostId, state) + } + return state +} + +async function readRecords(): Promise { + if (!registrationRecords) { + const stored = await loadRemotePushHostRegistrations() + registrationRecords ??= { + registered: new Set(stored.registeredHostIds), + pending: new Set(stored.pendingUnregisterHostIds) + } + } + return registrationRecords +} + +async function mutateRecords(mutate: (value: RegistrationRecords) => void): Promise { + const value = await readRecords() + mutate(value) + await saveRemotePushHostRegistrations({ + registeredHostIds: [...value.registered], + pendingUnregisterHostIds: [...value.pending] + }) +} + +// A missing token is retried: APNs registration may still be in flight. +async function currentToken(): Promise { + await ensureDesktopNotificationChannel() + if (!tokenPromise) { + const pending: Promise = getDevicePushToken().then((token) => { + if (!token && tokenPromise === pending) { + tokenPromise = null + } + return token + }) + tokenPromise = pending + } + return tokenPromise +} + +async function sendRegister( + client: PushClient, + token: MobilePushToken, + filter: MobilePushFilter +): Promise { + const params: Omit = { + platform: token.platform, + token: token.token, + ...(token.apnsEnvironment ? { apnsEnvironment: token.apnsEnvironment } : {}), + filter + } + const response = await client + .sendRequest('notifications.registerPush', params, { + timeoutMs: REQUEST_TIMEOUT_MS, + failWhenDisconnected: true + }) + .catch(() => null) + if (!response?.ok) { + return false + } + return (response.result as MobilePushRegisterResult | null)?.registered === true +} + +async function sendUnregister(client: PushClient, timeoutMs: number): Promise { + const response = await client + .sendRequest('notifications.unregisterPush', null, { + timeoutMs, + failWhenDisconnected: true + }) + .catch(() => null) + return response?.ok === true +} + +async function reconcileHost(hostId: string): Promise { + const state = hostsById.get(hostId) + const client = state?.connection.client + if (!state || !client) { + return + } + const generation = consentGeneration + const isCurrent = () => hostsById.get(hostId) === state && state.connection.client === client + const value = await readRecords() + // Unregister intent takes priority even before the capability probe answers. + if (value.pending.has(hostId)) { + if (state.supported === false || !(await sendUnregister(client, REQUEST_TIMEOUT_MS))) { + return + } + await mutateRecords((current) => { + current.pending.delete(hostId) + current.registered.delete(hostId) + }) + // A preference change can invalidate a register without disabling push. + if (!(await loadPushNotificationsEnabled())) { + return + } + } + if (state.supported == null) { + if (!isCurrent()) { + return + } + state.capabilityProbeStop ??= startRuntimeCapabilityProbe(client, (capabilities) => { + if (!isCurrent()) { + return + } + state.supported = capabilities.includes(NOTIFICATIONS_REMOTE_PUSH_CAPABILITY) + void enqueueReconcile(hostId) + }) + return + } + if (!state.supported || !isCurrent()) { + return + } + if (!(await loadPushNotificationsEnabled())) { + // Saved consent recovers a disable even if its pending-record write failed. + if (await sendUnregister(client, REQUEST_TIMEOUT_MS)) { + await mutateRecords((current) => { + current.pending.delete(hostId) + current.registered.delete(hostId) + }) + } + return + } + if (AppState.currentState !== 'active') { + return + } + let timer: ReturnType | undefined + const token = await Promise.race([ + currentToken(), + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), TOKEN_TIMEOUT_MS) + }) + ]).finally(() => clearTimeout(timer)) + const filter = notificationPreferencesFilter(await loadNotificationDeliveryPreferences()) + if ( + !token || + !isCurrent() || + generation !== consentGeneration || + AppState.currentState !== 'active' + ) { + return + } + if (!(await sendRegister(client, token, filter)) || hostsById.get(hostId) !== state) { + return + } + if (generation !== consentGeneration) { + await mutateRecords((current) => current.pending.add(hostId)) + void enqueueReconcile(hostId) + return + } + await mutateRecords((current) => current.registered.add(hostId)) +} + +function enqueueReconcile(hostId: string): Promise { + const state = hostState(hostId) + const run = state.chain + .then(() => (hostsById.get(hostId) === state ? reconcileHost(hostId) : undefined)) + .catch(() => { + console.warn('[push] Failed to reconcile notification registration') + }) + state.chain = run + return run +} + +async function reconcileAllHosts(): Promise { + await Promise.all([...hostsById.keys()].map((hostId) => enqueueReconcile(hostId))) +} + +/** + * Track a host whose client has reached `connected`, registering (or retrying a + * pending unregister) as the current preference requires. The returned function + * detaches the client on disconnect; the host's tracked state survives it. + */ +export function attachPushRegistration(hostId: string, client: PushClient): () => void { + const state = hostState(hostId) + if (state.connection.client !== client) { + state.capabilityProbeStop?.() + state.capabilityProbeStop = null + state.connection.client = client + state.supported = null + } + void enqueueReconcile(hostId) + const connection = state.connection + return () => { + if (connection.client === client) { + connection.client = null + state.capabilityProbeStop?.() + state.capabilityProbeStop = null + state.supported = null + const current = hostsById.get(hostId) + if (current && current !== state) { + current.capabilityProbeStop?.() + current.capabilityProbeStop = null + current.supported = null + } + } + } +} + +// Consent completion covers local persistence; host reconciliation runs in the background. +export async function setRemotePushEnabled(enabled: boolean): Promise { + consentGeneration++ + await savePushNotificationsEnabled(enabled) + try { + await mutateRecords((current) => { + if (!enabled) { + for (const hostId of current.registered) { + current.pending.add(hostId) + } + return + } + current.pending.clear() + }) + } finally { + void reconcileAllHosts() + } +} + +export async function setNotificationDeliveryPreferences( + value: NotificationDeliveryPreferences +): Promise { + consentGeneration++ + await saveNotificationDeliveryPreferences(value) + await reconcileAllHosts() +} + +// Offline hosts retain the registration until unpaired or its mobile-use lease expires. +export async function unregisterPushForRemovedHost(hostId: string): Promise<() => void> { + const state = hostsById.get(hostId) + // Retire ownership before waiting for earlier RPCs to settle. + hostsById.delete(hostId) + state?.capabilityProbeStop?.() + if (state) { + state.capabilityProbeStop = null + } + await state?.chain + if (state?.connection.client && state.supported !== false) { + await sendUnregister(state.connection.client, REMOVAL_TIMEOUT_MS) + } + await mutateRecords((current) => { + current.registered.delete(hostId) + current.pending.delete(hostId) + }).catch(() => {}) + return () => { + if (state && !hostsById.has(hostId)) { + // Preserve disconnect ownership without reviving stale registration work. + hostsById.set(hostId, { ...state, supported: null, capabilityProbeStop: null }) + void enqueueReconcile(hostId) + } + } +} + +/** A rolled token stops delivering, so re-register every connected host at once. */ +export function startPushTokenSync(): () => void { + const stopLease = startMobilePushLeaseRenewal(reconcileAllHosts) + const stopToken = addPushTokenListener((token) => { + tokenPromise = Promise.resolve(token) + void reconcileAllHosts() + }) + return () => { + stopLease() + stopToken() + } +} + +export function resetPushRegistrationForTests(): void { + hostsById.clear() + registrationRecords = null + tokenPromise = null + consentGeneration = 0 +} diff --git a/mobile/src/notifications/push-socket-dismissal.test.ts b/mobile/src/notifications/push-socket-dismissal.test.ts new file mode 100644 index 00000000000..80d3534c4ca --- /dev/null +++ b/mobile/src/notifications/push-socket-dismissal.test.ts @@ -0,0 +1,73 @@ +import { expect, it, vi } from 'vitest' +import * as Notifications from 'expo-notifications' +import { loadHostCatalog } from '../transport/host-store' +import { deriveHostFingerprint } from './push-host-fingerprint' +import { dismissHostPushNotification } from './push-socket-dismissal' +vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn() })) +vi.mock('expo-notifications', () => ({ + getPresentedNotificationsAsync: vi.fn(), + dismissNotificationAsync: vi.fn() +})) +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { getItem: async () => null, setItem: async () => undefined } +})) + +it('a socket dismissal cannot clear another desktop or a newer notification', async () => { + const publicKeyB64 = Buffer.alloc(32, 1).toString('base64') + vi.mocked(loadHostCatalog).mockResolvedValue([{ id: 'host-a', publicKeyB64 }] as never) + const hostFingerprint = deriveHostFingerprint(publicKeyB64) + const event = { + type: 'dismiss' as const, + notificationId: 'same', + notificationEpoch: 'epoch', + notificationSeq: 2 + } + const presented = (identifier: string, overrides: Record) => ({ + request: { identifier, content: { data: { hostFingerprint, ...event, ...overrides } } } + }) + vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([ + presented('older', { notificationSeq: 1 }), + presented('newer', { notificationSeq: 3 }), + presented('other', { hostFingerprint: 'other-host' }), + presented('restarted', { notificationEpoch: 'new-epoch' }) + ] as never) + vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined) + await dismissHostPushNotification(event, 'host-a') + expect(vi.mocked(Notifications.dismissNotificationAsync).mock.calls).toEqual([['older']]) +}) + +it('supports ID-only legacy dismissal while preserving host isolation', async () => { + vi.clearAllMocks() + const publicKeyB64 = Buffer.alloc(32, 1).toString('base64') + vi.mocked(loadHostCatalog).mockResolvedValue([{ id: 'host-a', publicKeyB64 }] as never) + const hostFingerprint = deriveHostFingerprint(publicKeyB64) + vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([ + { + request: { + identifier: 'versioned', + content: { + data: { + hostFingerprint, + notificationId: 'same', + notificationEpoch: 'new', + notificationSeq: 3 + } + } + } + }, + { + request: { + identifier: 'legacy', + content: { data: { hostFingerprint, notificationId: 'same' } } + } + }, + { + request: { + identifier: 'foreign', + content: { data: { hostFingerprint: 'other-host', notificationId: 'same' } } + } + } + ] as never) + await dismissHostPushNotification({ type: 'dismiss', notificationId: 'same' }, 'host-a') + expect(Notifications.dismissNotificationAsync).toHaveBeenCalledExactlyOnceWith('legacy') +}) diff --git a/mobile/src/notifications/push-socket-dismissal.ts b/mobile/src/notifications/push-socket-dismissal.ts new file mode 100644 index 00000000000..93226f296b2 --- /dev/null +++ b/mobile/src/notifications/push-socket-dismissal.ts @@ -0,0 +1,22 @@ +import { loadHostCatalog } from '../transport/host-store' +import { deriveHostFingerprint } from './push-host-fingerprint' +import { dismissPresentedPushNotification } from './push-tray-dismissal' +import type { DismissNotificationEvent } from './desktop-notification-events' + +async function hostFingerprint(hostId: string): Promise { + const hosts = await loadHostCatalog().catch(() => []) + const host = hosts.find((item) => item.id === hostId) + return host ? deriveHostFingerprint(host.publicKeyB64) : null +} + +export async function dismissHostPushNotification( + event: DismissNotificationEvent, + hostId: string +): Promise { + const fingerprint = await hostFingerprint(hostId) + if (!fingerprint) { + return + } + const fence = event.notificationEpoch && event.notificationSeq !== undefined ? event : undefined + await dismissPresentedPushNotification(event.notificationId, fingerprint, fence) +} diff --git a/mobile/src/notifications/push-token.test.ts b/mobile/src/notifications/push-token.test.ts new file mode 100644 index 00000000000..2a193430ac6 --- /dev/null +++ b/mobile/src/notifications/push-token.test.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as Notifications from 'expo-notifications' +import { addPushTokenListener, getDevicePushToken } from './push-token' + +vi.mock('expo-notifications', () => ({ + getDevicePushTokenAsync: vi.fn(), + addPushTokenListener: vi.fn() +})) + +const dev = globalThis as { __DEV__?: boolean } + +beforeEach(() => { + vi.clearAllMocks() +}) + +afterEach(() => { + delete dev.__DEV__ +}) + +describe('getDevicePushToken', () => { + it.each([ + [true, 'sandbox'], + [false, 'production'] + ])('reports apnsEnvironment for a __DEV__=%s iOS build as %s', async (isDev, environment) => { + dev.__DEV__ = isDev + vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ + type: 'ios', + data: 'a'.repeat(64) + } as never) + + await expect(getDevicePushToken()).resolves.toEqual({ + platform: 'ios', + token: 'a'.repeat(64), + apnsEnvironment: environment + }) + }) + + it('omits apnsEnvironment for Android, where FCM has no environment split', async () => { + vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ + type: 'android', + data: 'fcm-registration-token' + } as never) + + await expect(getDevicePushToken()).resolves.toEqual({ + platform: 'android', + token: 'fcm-registration-token' + }) + }) + + it.each([ + ['a web push subscription', { type: 'web', data: { endpoint: 'https://example.test' } }], + ['an empty token', { type: 'ios', data: '' }] + ])('returns null for %s', async (_label, raw) => { + vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue(raw as never) + + await expect(getDevicePushToken()).resolves.toBeNull() + }) + + it('returns null when the shell cannot mint a token at all', async () => { + vi.mocked(Notifications.getDevicePushTokenAsync).mockRejectedValue(new Error('no entitlement')) + + await expect(getDevicePushToken()).resolves.toBeNull() + }) +}) + +describe('addPushTokenListener', () => { + it('forwards a rolled native token and removes the subscription on teardown', () => { + const remove = vi.fn() + let emit: ((raw: unknown) => void) | null = null + vi.mocked(Notifications.addPushTokenListener).mockImplementation((listener) => { + emit = listener as (raw: unknown) => void + return { remove } as never + }) + const seen: unknown[] = [] + + const stop = addPushTokenListener((token) => seen.push(token)) + emit?.({ type: 'android', data: 'rolled' }) + emit?.({ type: 'web', data: {} }) + stop() + + expect(seen).toEqual([{ platform: 'android', token: 'rolled' }]) + expect(remove).toHaveBeenCalledTimes(1) + }) + + it('degrades to a no-op on a shell that cannot subscribe to token changes', () => { + vi.mocked(Notifications.addPushTokenListener).mockImplementation(() => { + throw new Error('no push support') + }) + + expect(() => addPushTokenListener(() => {})()).not.toThrow() + }) +}) diff --git a/mobile/src/notifications/push-token.ts b/mobile/src/notifications/push-token.ts new file mode 100644 index 00000000000..6c93bfb1506 --- /dev/null +++ b/mobile/src/notifications/push-token.ts @@ -0,0 +1,58 @@ +import * as Notifications from 'expo-notifications' +import type { + MobilePushApnsEnvironment, + MobilePushPlatform +} from '../../../src/shared/mobile-push-contract' + +// Why: the native APNs/FCM token, not an Expo push token — Orca's own gateway +// talks to Apple and Google directly, so it needs the raw device token. + +export type MobilePushToken = { + readonly platform: MobilePushPlatform + readonly token: string + readonly apnsEnvironment?: MobilePushApnsEnvironment +} + +// Dev-client builds are debug and get sandbox APNs; TestFlight and App Store are release. +function apnsEnvironment(): MobilePushApnsEnvironment { + return typeof __DEV__ !== 'undefined' && __DEV__ ? 'sandbox' : 'production' +} + +function toMobilePushToken(raw: { type: string; data: unknown }): MobilePushToken | null { + if (typeof raw.data !== 'string' || raw.data.length === 0) { + return null + } + if (raw.type === 'ios') { + return { platform: 'ios', token: raw.data, apnsEnvironment: apnsEnvironment() } + } + // Web tokens carry an object payload and no Orca gateway path; only native counts. + return raw.type === 'android' ? { platform: 'android', token: raw.data } : null +} + +/** + * The native push token, or null if registration is unavailable or fails. + */ +export async function getDevicePushToken(): Promise { + try { + return toMobilePushToken(await Notifications.getDevicePushTokenAsync()) + } catch { + return null + } +} + +/** Providers can roll a token while the app runs; the old one stops delivering. */ +export function addPushTokenListener(listener: (token: MobilePushToken) => void): () => void { + try { + const subscription = Notifications.addPushTokenListener((raw) => { + const token = toMobilePushToken(raw) + if (token) { + listener(token) + } + }) + return () => subscription.remove() + } catch { + // A shell with no push capability cannot subscribe; the caller is a root-level + // effect, so throwing here would take the whole app down over an optional feature. + return () => {} + } +} diff --git a/mobile/src/notifications/push-tray-dismissal.test.ts b/mobile/src/notifications/push-tray-dismissal.test.ts new file mode 100644 index 00000000000..5bea8f88f72 --- /dev/null +++ b/mobile/src/notifications/push-tray-dismissal.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as Notifications from 'expo-notifications' +import { dismissPresentedPushNotification } from './push-tray-dismissal' + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { getItem: vi.fn(async () => null), setItem: vi.fn(async () => {}) } +})) + +vi.mock('expo-notifications', () => ({ + getPresentedNotificationsAsync: vi.fn(), + dismissNotificationAsync: vi.fn() +})) + +function presented(identifier: string, data: unknown): unknown { + return { request: { identifier, content: { data } } } +} + +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined) +}) + +describe('dismissPresentedPushNotification', () => { + it('dismisses only the tray entries whose push payload carries the same notification id', async () => { + vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([ + presented('tray-1', { + orca: { hostFingerprint: 'fp0123456789abcd', notificationId: 'agent:one' } + }), + presented('tray-2', { + orca: { hostFingerprint: 'fp0123456789abcd', notificationId: 'agent:two' } + }), + presented('other-host', { hostFingerprint: 'another-host', notificationId: 'agent:one' }), + // Flat FCM shape for the same notification, presented on Android. + presented('tray-3', { hostFingerprint: 'fp0123456789abcd', notificationId: 'agent:one' }) + ] as never) + + await dismissPresentedPushNotification('agent:one', 'fp0123456789abcd') + + expect(vi.mocked(Notifications.dismissNotificationAsync).mock.calls.map(([id]) => id)).toEqual([ + 'tray-1', + 'tray-3' + ]) + }) + + it('ignores notifications without a gateway identity', async () => { + vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([ + presented('tray-1', { hostId: 'host-1', notificationId: 'agent:one' }) + ] as never) + + await dismissPresentedPushNotification('agent:one', 'fp0123456789abcd') + + expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalled() + }) + + it('reports tray query failures to the caller', async () => { + vi.mocked(Notifications.getPresentedNotificationsAsync).mockRejectedValue( + new Error('unavailable') + ) + + await expect(dismissPresentedPushNotification('agent:one', 'fp0123456789abcd')).rejects.toThrow( + 'unavailable' + ) + }) +}) + +it('a delayed dismissal preserves newer alerts, other epochs, and other hosts', async () => { + const base = { hostFingerprint: 'host-a', notificationId: 'note', notificationEpoch: 'epoch-a' } + vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([ + presented('older', { ...base, notificationSeq: 1 }), + presented('equal', { ...base, notificationSeq: 2 }), + presented('newer', { ...base, notificationSeq: 3 }), + presented('restarted', { ...base, notificationSeq: 1, notificationEpoch: 'epoch-b' }), + presented('other-host', { ...base, notificationSeq: 1, hostFingerprint: 'host-b' }), + presented('legacy', base) + ] as never) + await dismissPresentedPushNotification('note', 'host-a', { + notificationEpoch: 'epoch-a', + notificationSeq: 2 + }) + expect(vi.mocked(Notifications.dismissNotificationAsync).mock.calls.map(([id]) => id)).toEqual([ + 'older', + 'equal' + ]) +}) + +it.each([undefined, {}, { notificationEpoch: 'epoch' }, { notificationSeq: 2 }])( + 'an incomplete dismissal fence %j removes only unversioned entries', + async (fence) => { + const base = { hostFingerprint: 'host-a', notificationId: 'note' } + vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([ + presented('unversioned', base), + presented('versioned', { ...base, notificationEpoch: 'epoch', notificationSeq: 2 }), + presented('epoch-only', { ...base, notificationEpoch: 'epoch' }), + presented('sequence-only', { ...base, notificationSeq: 2 }) + ] as never) + await dismissPresentedPushNotification('note', 'host-a', fence) + expect(Notifications.dismissNotificationAsync).toHaveBeenCalledExactlyOnceWith('unversioned') + } +) diff --git a/mobile/src/notifications/push-tray-dismissal.ts b/mobile/src/notifications/push-tray-dismissal.ts new file mode 100644 index 00000000000..a810430709a --- /dev/null +++ b/mobile/src/notifications/push-tray-dismissal.ts @@ -0,0 +1,63 @@ +import { readNativeNotificationData } from './native-notification-data' +import * as Notifications from 'expo-notifications' +import { readOrcaPushPayload, type OrcaPushPayload } from './push-payload' +import { rememberPushDismissal, wasPushDismissed } from './push-dismissal-watermarks' + +async function dismissMatchingPresentedPushes( + matches: (payload: OrcaPushPayload) => boolean | Promise +): Promise { + const presented = await Notifications.getPresentedNotificationsAsync() + await Promise.all( + presented.map(async (notification) => { + const payload = readOrcaPushPayload(readNativeNotificationData(notification.request)) + if (payload && (await matches(payload))) { + await Notifications.dismissNotificationAsync(notification.request.identifier) + } + }) + ) +} + +export function dismissRememberedPushNotifications( + hostFingerprint: string, + confirmed: readonly OrcaPushPayload[] +): Promise { + return dismissMatchingPresentedPushes(async (payload) => { + if (payload.hostFingerprint !== hostFingerprint) { + return false + } + return ( + confirmed.some( + (fence) => + fence.notificationId === payload.notificationId && + fence.notificationEpoch === payload.notificationEpoch && + fence.notificationSeq !== undefined && + payload.notificationSeq !== undefined && + fence.notificationSeq >= payload.notificationSeq + ) || wasPushDismissed(payload) + ) + }) +} + +// Pushes shown while Orca was closed are absent from the local scheduling registry. +export async function dismissPresentedPushNotification( + notificationId: string, + hostFingerprint: string, + fence?: { notificationEpoch?: string; notificationSeq?: number } +): Promise { + if (fence) { + await rememberPushDismissal({ hostFingerprint, notificationId, ...fence }) + } + await dismissMatchingPresentedPushes((payload) => { + if (payload.hostFingerprint !== hostFingerprint) { + return false + } + return ( + payload.notificationId === notificationId && + (fence?.notificationEpoch && fence.notificationSeq !== undefined + ? payload.notificationEpoch === fence.notificationEpoch && + payload.notificationSeq !== undefined && + payload.notificationSeq <= fence.notificationSeq + : payload.notificationEpoch === undefined && payload.notificationSeq === undefined) + ) + }) +} diff --git a/mobile/src/notifications/use-remote-push-capable-hosts.test.tsx b/mobile/src/notifications/use-remote-push-capable-hosts.test.tsx new file mode 100644 index 00000000000..9b25ca3dcf9 --- /dev/null +++ b/mobile/src/notifications/use-remote-push-capable-hosts.test.tsx @@ -0,0 +1,196 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { loadHostCatalog } from '../transport/host-store' +import type { HostCatalogEntry } from '../transport/types' +import type { RpcClient } from '../transport/rpc-client' +import { startRuntimeCapabilityProbe } from '../transport/runtime-capability-probe' +import { useAllHostClients } from '../transport/use-all-host-clients' +import { + useRemotePushCapableHosts, + type RemotePushHostSupport +} from './use-remote-push-capable-hosts' + +vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn() })) +vi.mock('../transport/use-all-host-clients', () => ({ useAllHostClients: vi.fn() })) +vi.mock('../transport/runtime-capability-probe', () => ({ + startRuntimeCapabilityProbe: vi.fn() +})) + +// The real module reaches expo-notifications and the preference store for the token +// path; only the capability string matters here. +vi.mock('./push-registration', () => ({ + NOTIFICATIONS_REMOTE_PUSH_CAPABILITY: 'notifications.remote-push.v1' +})) + +const CAPABILITY = 'notifications.remote-push.v1' + +type ClientEntry = { hostId: string; client: RpcClient; state: string } + +/** Distinct object per host, so identity changes are the thing under test. */ +function clientFor(hostId: string): RpcClient { + return { hostId } as unknown as RpcClient +} + +let renderer: ReactTestRenderer | null = null +let latest: RemotePushHostSupport = { supported: false, resolved: false } +const answerByHostId = new Map void>() +const stopProbe = vi.fn() + +function Harness(): null { + latest = useRemotePushCapableHosts() + return null +} + +async function mount(): Promise { + await act(async () => { + renderer = create(createElement(Harness)) + await Promise.resolve() + }) +} + +async function setClients(entries: readonly ClientEntry[]): Promise { + vi.mocked(useAllHostClients).mockReturnValue(entries as never) + await act(async () => { + renderer?.update(createElement(Harness)) + await Promise.resolve() + }) +} + +async function answer(hostId: string, capabilities: readonly string[]): Promise { + await act(async () => { + answerByHostId.get(hostId)?.(capabilities) + await Promise.resolve() + }) +} + +beforeEach(() => { + vi.clearAllMocks() + answerByHostId.clear() + latest = { supported: false, resolved: false } + vi.mocked(useAllHostClients).mockReturnValue([] as never) + vi.mocked(startRuntimeCapabilityProbe).mockImplementation((client, onCapabilities) => { + answerByHostId.set((client as unknown as { hostId: string }).hostId, onCapabilities) + return stopProbe + }) + vi.mocked(loadHostCatalog).mockResolvedValue([ + { id: 'host-1', publicKeyB64: 'k1' }, + { id: 'host-2', publicKeyB64: 'k2' } + ] as unknown as HostCatalogEntry[]) +}) + +afterEach(() => { + act(() => renderer?.unmount()) + renderer = null +}) + +describe('useRemotePushCapableHosts', () => { + it('stays unresolved when the host catalog cannot be read', async () => { + vi.mocked(loadHostCatalog).mockRejectedValue(new Error('keychain locked')) + + await mount() + + // Resolving here would render "Update your desktop app" at someone whose desktop + // is already current, on the strength of a catalog read that simply failed. + expect(latest).toEqual({ supported: false, resolved: false }) + }) + + it('waits for every connected host before answering', async () => { + await mount() + await setClients([ + { hostId: 'host-1', client: clientFor('host-1'), state: 'connected' }, + { hostId: 'host-2', client: clientFor('host-2'), state: 'connected' } + ]) + + await answer('host-1', [CAPABILITY]) + expect(latest.resolved).toBe(false) + + await answer('host-2', ['some-other.v1']) + expect(latest).toEqual({ supported: true, resolved: true }) + }) + + it('keeps the answer of a host that has since disconnected', async () => { + await mount() + const client = clientFor('host-1') + await setClients([{ hostId: 'host-1', client, state: 'connected' }]) + await answer('host-1', [CAPABILITY]) + + await setClients([{ hostId: 'host-1', client, state: 'connecting' }]) + + expect(latest).toEqual({ supported: true, resolved: true }) + }) + + it('resolves immediately when nothing is paired', async () => { + vi.mocked(loadHostCatalog).mockResolvedValue([]) + + await mount() + + expect(latest).toEqual({ supported: false, resolved: true }) + }) + + it('rechecks a cached answer after disconnecting and reconnecting', async () => { + await mount() + const client = clientFor('host-1') + await setClients([{ hostId: 'host-1', client, state: 'connected' }]) + await answer('host-1', [CAPABILITY]) + await setClients([{ hostId: 'host-1', client, state: 'connecting' }]) + expect(latest).toEqual({ supported: true, resolved: true }) + await setClients([{ hostId: 'host-1', client, state: 'connected' }]) + expect(latest).toEqual({ supported: false, resolved: false }) + await answer('host-1', []) + expect(latest).toEqual({ supported: false, resolved: true }) + }) + + it('leaves a running probe alone when another host changes state', async () => { + await mount() + const first = clientFor('host-1') + await setClients([{ hostId: 'host-1', client: first, state: 'connected' }]) + expect(startRuntimeCapabilityProbe).toHaveBeenCalledTimes(1) + + // useAllHostClients rebuilds its array on every connection tick, so a plain + // dependency on it would tear down and restart host-1's probe here. + await setClients([ + { hostId: 'host-1', client: first, state: 'connected' }, + { hostId: 'host-2', client: clientFor('host-2'), state: 'connecting' } + ]) + await setClients([ + { hostId: 'host-1', client: first, state: 'connected' }, + { hostId: 'host-2', client: clientFor('host-2'), state: 'connected' } + ]) + + expect(stopProbe).not.toHaveBeenCalled() + expect( + vi.mocked(startRuntimeCapabilityProbe).mock.calls.map(([client]) => client) + ).toHaveLength(2) + }) + + it('restarts the probe when a reconnect replaces the host client', async () => { + await mount() + await setClients([{ hostId: 'host-1', client: clientFor('host-1'), state: 'connected' }]) + await answer('host-1', [CAPABILITY]) + expect(latest).toEqual({ supported: true, resolved: true }) + + await setClients([{ hostId: 'host-1', client: clientFor('host-1'), state: 'connected' }]) + + expect(latest).toEqual({ supported: false, resolved: false }) + expect(stopProbe).toHaveBeenCalledTimes(1) + expect(startRuntimeCapabilityProbe).toHaveBeenCalledTimes(2) + await answer('host-1', []) + expect(latest).toEqual({ supported: false, resolved: true }) + await answer('host-1', [CAPABILITY]) + expect(latest).toEqual({ supported: true, resolved: true }) + }) + + it('ignores an answer from a host the catalog no longer lists', async () => { + await mount() + await setClients([ + { hostId: 'host-ghost', client: clientFor('host-ghost'), state: 'connected' } + ]) + + await answer('host-ghost', [CAPABILITY]) + + // An unpaired desktop cannot push to this phone, so its vote must not offer + // the switch — nor count as the answer that resolves the section. + expect(latest).toEqual({ supported: false, resolved: false }) + }) +}) diff --git a/mobile/src/notifications/use-remote-push-capable-hosts.ts b/mobile/src/notifications/use-remote-push-capable-hosts.ts new file mode 100644 index 00000000000..243bbbc6ce8 --- /dev/null +++ b/mobile/src/notifications/use-remote-push-capable-hosts.ts @@ -0,0 +1,109 @@ +import { useEffect, useRef, useState } from 'react' +import { loadHostCatalog } from '../transport/host-store' +import type { RpcClient } from '../transport/rpc-client' +import { startRuntimeCapabilityProbe } from '../transport/runtime-capability-probe' +import { useAllHostClients } from '../transport/use-all-host-clients' +import { NOTIFICATIONS_REMOTE_PUSH_CAPABILITY } from './push-registration' + +export type RemotePushHostSupport = { + /** At least one paired host advertises `notifications.remote-push.v1`. */ + supported: boolean + /** Whether the answer above is final rather than "nobody has replied yet". */ + resolved: boolean +} + +/** + * Whether background push can be offered at all. The desktop advertises the + * capability in `status.get`, so the answer needs a connected host — until one + * replies the screen must stay silent rather than tell someone to update a + * desktop that is already current. + */ +export function useRemotePushCapableHosts(): RemotePushHostSupport { + const [hostIds, setHostIds] = useState([]) + const [hostsLoaded, setHostsLoaded] = useState(false) + const [supportedByHostId, setSupportedByHostId] = useState>({}) + const probesRef = useRef(new Map void }>()) + + useEffect(() => { + let cancelled = false + void loadHostCatalog() + .then((hosts) => { + if (!cancelled) { + setHostIds(hosts.map((host) => host.id)) + setHostsLoaded(true) + } + }) + // Why nothing on failure: an unread catalog marked loaded resolves the answer as + // "no paired host supports push", which tells the user to update a current desktop. + .catch(() => {}) + return () => { + cancelled = true + } + }, []) + + const clients = useAllHostClients(hostIds) + + // Why pruned rather than left: an answer for a host that is no longer paired is a + // vote from a desktop this phone cannot receive a push from. + useEffect(() => { + setSupportedByHostId((previous) => { + const kept = Object.entries(previous).filter(([hostId]) => hostIds.includes(hostId)) + return kept.length === Object.keys(previous).length ? previous : Object.fromEntries(kept) + }) + }, [hostIds]) + + // Why diffed by client identity rather than restarted on every `clients` value: + // useAllHostClients rebuilds the array on each connection tick, so a plain + // dependency tears down and re-runs every host's probe whenever any host moves. + useEffect(() => { + const connected = new Map( + clients + .filter((entry) => entry.state === 'connected') + .map((entry) => [entry.hostId, entry.client]) + ) + const probes = probesRef.current + for (const [hostId, probe] of probes) { + if (connected.get(hostId) !== probe.client) { + probe.stop() + probes.delete(hostId) + } + } + for (const [hostId, client] of connected) { + if (!probes.has(hostId)) { + setSupportedByHostId((previous) => { + const { [hostId]: _removed, ...remaining } = previous + return remaining + }) + const stop = startRuntimeCapabilityProbe(client, (capabilities) => { + setSupportedByHostId((previous) => ({ + ...previous, + [hostId]: capabilities.includes(NOTIFICATIONS_REMOTE_PUSH_CAPABILITY) + })) + }) + probes.set(hostId, { client, stop }) + } + } + }, [clients]) + + useEffect(() => { + const probes = probesRef.current + return () => { + for (const probe of probes.values()) { + probe.stop() + } + probes.clear() + } + }, []) + + const answeredHostIds = hostIds.filter((hostId) => hostId in supportedByHostId) + return { + supported: answeredHostIds.some((hostId) => supportedByHostId[hostId]), + // A connected host that has not answered yet is exactly the case the silence is + // for, so one outstanding probe holds the whole section back. Disconnected hosts + // do not: their earlier answer stands, and one that never answered never will. + resolved: + (hostsLoaded && hostIds.length === 0) || + (answeredHostIds.length > 0 && + clients.every((entry) => entry.state !== 'connected' || entry.hostId in supportedByHostId)) + } +} diff --git a/mobile/src/onboarding/MobileOnboardingPage.tsx b/mobile/src/onboarding/MobileOnboardingPage.tsx index a5a6a07a3c6..65a19b57889 100644 --- a/mobile/src/onboarding/MobileOnboardingPage.tsx +++ b/mobile/src/onboarding/MobileOnboardingPage.tsx @@ -47,16 +47,26 @@ export function MobileOnboardingPage({ )} - {isSessionView ? 'How should sessions open?' : 'Stay updated while away'} + {isSessionView ? 'How should sessions open?' : 'Enable notifications'} {isSessionView ? 'Choose whether supported agent sessions open in the terminal or Chat UI on this device. Press and hold a session tab to switch its view, or change the default later in Settings.' - : 'Get notified on this device when an agent needs your input or finishes a task.'} + : 'Get notified when an agent finishes a task or needs your input.'} + {!isSessionView ? ( + + By default, notifications arrive after your desktop has been idle for 3 minutes. + + ) : null} + {!isSessionView ? ( + + Delivered through Orca’s push service. Change this anytime in Settings. + + ) : null} {error ? ( {error} diff --git a/mobile/src/onboarding/mobile-onboarding-screen.test.ts b/mobile/src/onboarding/mobile-onboarding-screen.test.ts index 05dcc6b9ad8..efa2f19d5ac 100644 --- a/mobile/src/onboarding/mobile-onboarding-screen.test.ts +++ b/mobile/src/onboarding/mobile-onboarding-screen.test.ts @@ -10,7 +10,7 @@ const mocks = vi.hoisted(() => ({ animatedTiming: vi.fn(), ensureNotificationPermissions: vi.fn(), saveDefaultSessionView: vi.fn(), - savePushNotificationsEnabled: vi.fn() + setRemotePushEnabled: vi.fn() })) vi.mock('react-native', () => ({ @@ -48,8 +48,8 @@ vi.mock('../notifications/mobile-notifications', () => ({ vi.mock('../storage/session-view-preferences', () => ({ saveDefaultSessionView: mocks.saveDefaultSessionView })) -vi.mock('../storage/preferences', () => ({ - savePushNotificationsEnabled: mocks.savePushNotificationsEnabled +vi.mock('../notifications/push-registration', () => ({ + setRemotePushEnabled: mocks.setRemotePushEnabled })) describe('MobileOnboardingScreen', () => { @@ -64,7 +64,7 @@ describe('MobileOnboardingScreen', () => { }) mocks.ensureNotificationPermissions.mockReset().mockResolvedValue(true) mocks.saveDefaultSessionView.mockReset().mockResolvedValue(undefined) - mocks.savePushNotificationsEnabled.mockReset().mockResolvedValue(undefined) + mocks.setRemotePushEnabled.mockReset().mockResolvedValue(undefined) }) afterEach(() => { @@ -100,7 +100,32 @@ describe('MobileOnboardingScreen', () => { await act(async () => pages()[1].props.onNotificationChoice('skip')) expect(mocks.ensureNotificationPermissions).not.toHaveBeenCalled() - expect(mocks.savePushNotificationsEnabled).toHaveBeenCalledWith(false) + expect(mocks.setRemotePushEnabled).toHaveBeenCalledWith(false) + expect(mocks.replace).toHaveBeenCalledWith('/h/paired-host') + }) + + it.each([true, false])( + 'saves permission result %s through the consent owner once', + async (granted) => { + mocks.params = { hostId: 'paired-host', steps: 'notifications' } + mocks.ensureNotificationPermissions.mockResolvedValue(granted) + await renderScreen() + await act(async () => pages()[0].props.onNotificationChoice('enable')) + expect(mocks.setRemotePushEnabled).toHaveBeenCalledExactlyOnceWith(granted) + expect(mocks.replace).toHaveBeenCalledWith('/h/paired-host') + } + ) + + it('keeps notification consent retryable when its local write fails', async () => { + mocks.params = { hostId: 'paired-host', steps: 'notifications' } + mocks.setRemotePushEnabled.mockRejectedValueOnce(new Error('disk full')) + await renderScreen() + await act(async () => pages()[0].props.onNotificationChoice('enable')) + expect(pages()[0].props.error).toBe('Notification settings could not be updated. Try again.') + expect(mocks.replace).not.toHaveBeenCalled() + await act(async () => pages()[0].props.onNotificationChoice('enable')) + expect(mocks.setRemotePushEnabled).toHaveBeenCalledTimes(2) + expect(mocks.setRemotePushEnabled).toHaveBeenLastCalledWith(true) expect(mocks.replace).toHaveBeenCalledWith('/h/paired-host') }) diff --git a/mobile/src/onboarding/mobile-onboarding-styles.ts b/mobile/src/onboarding/mobile-onboarding-styles.ts index 20f36ec0f5b..3f03bb24b95 100644 --- a/mobile/src/onboarding/mobile-onboarding-styles.ts +++ b/mobile/src/onboarding/mobile-onboarding-styles.ts @@ -90,6 +90,13 @@ export const mobileOnboardingStyles = StyleSheet.create({ alignSelf: 'center', paddingBottom: spacing.lg }, + disclosure: { + color: colors.textSecondary, + fontSize: typography.metaSize, + lineHeight: 18, + textAlign: 'center', + marginBottom: spacing.lg + }, primaryButton: { minHeight: 44, alignItems: 'center', diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts index df8aa1dd904..76435561664 100644 --- a/mobile/src/session/mobile-session-route-parity.test.ts +++ b/mobile/src/session/mobile-session-route-parity.test.ts @@ -62,8 +62,8 @@ const HOST_COMPONENT_NAMES = new Set([ 'View' ]) -const HEAD_MAIN_HOOK_SHA256 = '10071240ef9edafc2b9c8bed73be83dceaf7828e3b29f17dab55da020a7697a6' -const HEAD_HOOK_BINDING_SHA256 = '1dadb8c3dc0573ea20659ce7251629669e618dd0effaeac3a4536b29c2e865a1' +const HEAD_MAIN_HOOK_SHA256 = 'c3e33699e3e3fa7e24408f3d4946fcc451e9b9419442d985c4ccde01782e5114' +const HEAD_HOOK_BINDING_SHA256 = '7f907e028893721d662eeee0aa9002ad1e00359948f39fb148d274596cd9b3c0' const HEAD_CALLBACK_IDENTITY_SHA256 = '2a9e4825df007f6ef53b81aa5004991d6318eee7507b44d625c07e630be432eb' const HEAD_CALLBACK_BODY_SHA256 = 'af7f3c62954250d4be7ee432ecd10dc2689792aad8230fed2d1d68bbc892d776' @@ -472,7 +472,7 @@ describe('mobile session route extraction parity', () => { const contentBindings = CONTENT_COMPONENT_NAMES.flatMap( (name) => readHookFacts(name, definitions).bindings ) - expect(main.hooks).toHaveLength(266) + expect(main.hooks).toHaveLength(267) expect(hash(main.hooks)).toBe(HEAD_MAIN_HOOK_SHA256) expect(hash(main.bindings)).toBe(HEAD_HOOK_BINDING_SHA256) expect(main.callbacks).toHaveLength(77) diff --git a/mobile/src/session/mobile-session-route.ts b/mobile/src/session/mobile-session-route.ts index 66f5c66f76f..b9ec89296c7 100644 --- a/mobile/src/session/mobile-session-route.ts +++ b/mobile/src/session/mobile-session-route.ts @@ -3,6 +3,7 @@ import type { HostStackRouteTarget } from '../navigation/host-stack-navigation' export type MobileSessionRouteParams = { hostId: string worktreeId: string + paneKey?: string name?: string } @@ -11,10 +12,11 @@ export type MobileSessionRouteParams = { export function mobileSessionRouteTarget({ hostId, worktreeId, - name + name, + paneKey }: MobileSessionRouteParams): HostStackRouteTarget { return { name: '[hostId]/session/[worktreeId]', - params: name ? { hostId, worktreeId, name } : { hostId, worktreeId } + params: { hostId, worktreeId, ...(name ? { name } : {}), ...(paneKey ? { paneKey } : {}) } } } diff --git a/mobile/src/session/use-mobile-session-controller.ts b/mobile/src/session/use-mobile-session-controller.ts index f188b30b17a..9847a5a5065 100644 --- a/mobile/src/session/use-mobile-session-controller.ts +++ b/mobile/src/session/use-mobile-session-controller.ts @@ -1,3 +1,4 @@ +import { useNotificationPaneNavigation } from './use-notification-pane-navigation' import { useMobileSessionFoundation } from './use-mobile-session-foundation' import { useMobileSessionScreenState } from './use-mobile-session-screen-state' import { useMobileSessionTerminalRuntime } from './use-mobile-session-terminal-runtime' @@ -82,6 +83,7 @@ export function useMobileSessionController() { useMobileSessionStartup(keyboardState) useMobileSessionPreferenceFocus(keyboardState) const tabSwitching = Object.assign(keyboardState, useMobileSessionTabSwitching(keyboardState)) + useNotificationPaneNavigation(tabSwitching) const terminalWebview = Object.assign(tabSwitching, useMobileSessionTerminalWebview(tabSwitching)) const terminalSendActions = Object.assign( terminalWebview, diff --git a/mobile/src/session/use-notification-pane-navigation.test.tsx b/mobile/src/session/use-notification-pane-navigation.test.tsx new file mode 100644 index 00000000000..1c3af0fdc95 --- /dev/null +++ b/mobile/src/session/use-notification-pane-navigation.test.tsx @@ -0,0 +1,67 @@ +import { createElement } from 'react' +import { act, create } from 'react-test-renderer' +import { expect, it, vi } from 'vitest' +import { + notificationPaneTab, + useNotificationPaneNavigation +} from './use-notification-pane-navigation' +import type { MobileSessionTab } from './mobile-session-route-types' +const route = vi.hoisted(() => ({ paneKey: '', setParams: vi.fn() })) +vi.mock('expo-router', () => ({ + useLocalSearchParams: () => ({ paneKey: route.paneKey }), + useRouter: () => ({ setParams: route.setParams }) +})) +const leaf = '11111111-1111-4111-8111-111111111111' +const tabs: MobileSessionTab[] = [ + { + type: 'terminal', + id: 'first', + parentTabId: 'tab-a', + leafId: leaf, + title: 'first', + terminal: 'pty-a', + isActive: true + }, + { + type: 'terminal', + id: 'second', + parentTabId: 'tab-b', + leafId: leaf, + title: 'agent', + terminal: 'pty-b', + isActive: false + } +] +it('selects the originating split pane, not the first tab; closed and invalid panes fall back', () => { + expect(notificationPaneTab(tabs, `tab-b:${leaf}`)).toBe(tabs[1]) + expect(notificationPaneTab(tabs, `closed:${leaf}`)).toBeUndefined() + expect(notificationPaneTab(tabs, 'invalid')).toBeUndefined() +}) +it('waits for tabs, switches through the existing action, and consumes the navigation request', async () => { + route.paneKey = `tab-b:${leaf}` + const switchSessionTab = vi.fn() + function Probe({ loaded }: { loaded: boolean }) { + useNotificationPaneNavigation({ + sessionTabs: loaded ? tabs : [], + terminalsLoaded: loaded, + switchSessionTab + }) + return null + } + let renderer: ReturnType + await act(async () => { + renderer = create(createElement(Probe, { loaded: false })) + }) + expect(switchSessionTab).not.toHaveBeenCalled() + await act(async () => { + renderer.update(createElement(Probe, { loaded: true })) + }) + expect(switchSessionTab).toHaveBeenCalledExactlyOnceWith(tabs[1]) + expect(route.setParams).toHaveBeenCalledWith({ paneKey: '' }) + route.paneKey = '' + await act(async () => { + renderer.update(createElement(Probe, { loaded: true })) + }) + expect(switchSessionTab).toHaveBeenCalledOnce() + await act(async () => renderer.unmount()) +}) diff --git a/mobile/src/session/use-notification-pane-navigation.ts b/mobile/src/session/use-notification-pane-navigation.ts new file mode 100644 index 00000000000..f6d3bb15cd4 --- /dev/null +++ b/mobile/src/session/use-notification-pane-navigation.ts @@ -0,0 +1,40 @@ +import { useEffect } from 'react' +import { useLocalSearchParams, useRouter } from 'expo-router' +import { parsePaneKey } from '../../../src/shared/stable-pane-id' +import type { MobileSessionTab } from './mobile-session-route-types' + +export function notificationPaneTab(tabs: readonly MobileSessionTab[], paneKey: string) { + const pane = parsePaneKey(paneKey) + if (!pane) { + return undefined + } + return tabs.find((tab) => + tab.type === 'terminal' + ? (tab.parentTabId ?? tab.id) === pane.tabId && tab.leafId === pane.leafId + : tab.type === 'agent-session' && tab.id === pane.tabId + ) +} + +export function useNotificationPaneNavigation({ + sessionTabs, + terminalsLoaded, + switchSessionTab +}: { + sessionTabs: MobileSessionTab[] + terminalsLoaded: boolean + switchSessionTab: (tab: MobileSessionTab) => void +}) { + const { paneKey } = useLocalSearchParams<{ paneKey?: string }>() + const router = useRouter() + useEffect(() => { + if (!terminalsLoaded || typeof paneKey !== 'string' || !paneKey) { + return + } + const tab = notificationPaneTab(sessionTabs, paneKey) + // Consume the tap even if the pane was closed; later snapshots must not steal selection. + router.setParams({ paneKey: '' }) + if (tab) { + switchSessionTab(tab) + } + }, [paneKey, terminalsLoaded, sessionTabs, switchSessionTab, router]) +} diff --git a/mobile/src/settings/native-notification-delivery-settings.test.tsx b/mobile/src/settings/native-notification-delivery-settings.test.tsx new file mode 100644 index 00000000000..bf94b75649e --- /dev/null +++ b/mobile/src/settings/native-notification-delivery-settings.test.tsx @@ -0,0 +1,151 @@ +import { createElement, useEffect } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { NativeNotificationDeliverySettings } from './native-notification-delivery-settings' + +const mocks = vi.hoisted(() => ({ + load: vi.fn(), + save: vi.fn(), + support: { resolved: true, supported: false }, + appState: null as null | ((state: string) => void) +})) +vi.mock('react-native', () => ({ + Text: 'Text', + AppState: { + addEventListener: (_event: string, callback: (state: string) => void) => { + mocks.appState = callback + return { remove() {} } + } + } +})) +vi.mock('expo-router', () => ({ + useFocusEffect: (callback: () => void) => useEffect(callback, [callback]) +})) +vi.mock('../notifications/NotificationDeliverySection', () => ({ + NotificationDeliverySection: 'Delivery' +})) +vi.mock('../notifications/notification-delivery-preferences', () => ({ + DEFAULT_NOTIFICATION_DELIVERY: { + onlyWhenDesktopAway: true, + sound: true, + suppressWhileViewing: true + }, + loadNotificationDeliveryPreferences: mocks.load +})) +vi.mock('../notifications/push-registration', () => ({ + setNotificationDeliveryPreferences: mocks.save +})) +vi.mock('../notifications/use-remote-push-capable-hosts', () => ({ + useRemotePushCapableHosts: () => mocks.support +})) +let renderer: ReactTestRenderer +const preferences = { onlyWhenDesktopAway: false, sound: false, suppressWhileViewing: true } +beforeEach(() => { + mocks.load.mockReset().mockResolvedValue(preferences) + mocks.save.mockReset().mockResolvedValue(undefined) + mocks.support = { resolved: true, supported: false } +}) +afterEach(() => { + act(() => renderer?.unmount()) +}) +const section = () => renderer.root.findByType('Delivery').props +it('keeps stored controls visible but disabled without consent and explains an old host', async () => { + await act(async () => { + renderer = create(createElement(NativeNotificationDeliverySettings, { enabled: false })) + }) + expect(section().value).toEqual(preferences) + expect(section().disabled).toBe(true) + expect(JSON.stringify(renderer.toJSON())).toContain('Pair an updated desktop') + await act(async () => { + renderer.update(createElement(NativeNotificationDeliverySettings, { enabled: true })) + }) + expect(section().disabled).toBe(false) +}) +it('disables edits until preferences load, then waits for save and retains the prior value on failure', async () => { + let load!: (value: typeof preferences) => void + mocks.load.mockReturnValue( + new Promise((resolve) => { + load = resolve + }) + ) + await act(async () => { + renderer = create(createElement(NativeNotificationDeliverySettings, { enabled: true })) + }) + expect(section().disabled).toBe(true) + await act(async () => { + load(preferences) + }) + let reject!: (error: Error) => void + mocks.save.mockReturnValue( + new Promise((_resolve, fail) => { + reject = fail + }) + ) + await act(async () => { + section().onChange({ ...preferences, sound: true }) + }) + expect(section().disabled).toBe(true) + await act(async () => { + reject(new Error('storage unavailable')) + }) + expect(section().value).toEqual(preferences) + expect(section().disabled).toBe(false) + expect(JSON.stringify(renderer.toJSON())).toContain('Could not save delivery settings') +}) +it('does not claim an upgrade is needed while probing or when a host supports push', async () => { + mocks.support = { resolved: false, supported: false } + await act(async () => { + renderer = create(createElement(NativeNotificationDeliverySettings, { enabled: true })) + }) + expect(JSON.stringify(renderer.toJSON())).not.toContain('Pair an updated desktop') + mocks.support = { resolved: true, supported: true } + await act(async () => { + renderer.update(createElement(NativeNotificationDeliverySettings, { enabled: true })) + }) + expect(JSON.stringify(renderer.toJSON())).not.toContain('Pair an updated desktop') +}) + +it.each(['resolve', 'reject'])('ignores a pre-save refresh that later %ss', async (outcome) => { + await act(async () => { + renderer = create(createElement(NativeNotificationDeliverySettings, { enabled: true })) + }) + let resolve!: (value: typeof preferences) => void + let reject!: (error: Error) => void + mocks.load.mockReturnValue( + new Promise((ok, fail) => { + resolve = ok + reject = fail + }) + ) + await act(async () => mocks.appState!('active')) + const saved = { ...preferences, sound: true } + await act(async () => section().onChange(saved)) + await act(async () => { + if (outcome === 'resolve') { + resolve(preferences) + } else { + reject(new Error('old read failed')) + } + }) + expect(section().value).toEqual(saved) + expect(JSON.stringify(renderer.toJSON())).not.toContain('Could not load') + await act(async () => section().onChange({ ...section().value, suppressWhileViewing: false })) + expect(mocks.save).toHaveBeenLastCalledWith({ ...saved, suppressWhileViewing: false }) +}) + +it('does not refresh while a save is in flight', async () => { + await act(async () => { + renderer = create(createElement(NativeNotificationDeliverySettings, { enabled: true })) + }) + let finish!: () => void + mocks.save.mockReturnValue( + new Promise((resolve) => { + finish = resolve + }) + ) + await act(async () => section().onChange({ ...preferences, sound: true })) + await act(async () => mocks.appState!('active')) + expect(mocks.load).toHaveBeenCalledTimes(1) + await act(async () => finish()) + expect(section().value.sound).toBe(true) +}) diff --git a/mobile/src/settings/native-notification-delivery-settings.tsx b/mobile/src/settings/native-notification-delivery-settings.tsx new file mode 100644 index 00000000000..109e28c6c69 --- /dev/null +++ b/mobile/src/settings/native-notification-delivery-settings.tsx @@ -0,0 +1,97 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { AppState, Text } from 'react-native' +import { useFocusEffect } from 'expo-router' +import { NotificationDeliverySection } from '../notifications/NotificationDeliverySection' +import { + DEFAULT_NOTIFICATION_DELIVERY, + loadNotificationDeliveryPreferences, + type NotificationDeliveryPreferences +} from '../notifications/notification-delivery-preferences' +import { setNotificationDeliveryPreferences } from '../notifications/push-registration' +import { useRemotePushCapableHosts } from '../notifications/use-remote-push-capable-hosts' +import { colors, spacing, typography } from '../theme/mobile-theme' + +export function NativeNotificationDeliverySettings({ enabled }: { enabled: boolean }) { + const [delivery, setDelivery] = useState(DEFAULT_NOTIFICATION_DELIVERY) + const [loaded, setLoaded] = useState(false) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const refreshRevision = useRef(0) + const saveInProgress = useRef(false) + const support = useRemotePushCapableHosts() + const refresh = useCallback(async () => { + if (saveInProgress.current) { + return + } + const revision = ++refreshRevision.current + try { + const value = await loadNotificationDeliveryPreferences() + if (revision !== refreshRevision.current) { + return + } + setDelivery(value) + setLoaded(true) + setError(null) + } catch { + if (revision !== refreshRevision.current) { + return + } + setError('Could not load delivery settings. Reopen this screen to retry.') + } + }, []) + useFocusEffect( + useCallback(() => { + void refresh() + }, [refresh]) + ) + useEffect(() => { + const subscription = AppState.addEventListener('change', (state) => { + if (state === 'active') { + void refresh() + } + }) + return () => subscription.remove() + }, [refresh]) + const change = async (value: NotificationDeliveryPreferences) => { + if (saveInProgress.current) { + return + } + saveInProgress.current = true + refreshRevision.current += 1 + setSaving(true) + setError(null) + try { + await setNotificationDeliveryPreferences(value) + setDelivery(value) + } catch { + setError('Could not save delivery settings. Try again.') + } finally { + saveInProgress.current = false + setSaving(false) + } + } + const hintStyle = { + color: colors.textMuted, + fontSize: typography.metaSize, + marginTop: spacing.md + } + return ( + <> + void change(value)} + /> + {error && ( + + {error} + + )} + {support.resolved && !support.supported && ( + + Pair an updated desktop to receive notifications on this phone. + + )} + + ) +} diff --git a/mobile/src/settings/native-notification-settings-operations.ts b/mobile/src/settings/native-notification-settings-operations.ts index cd5da9584bf..4817a77c8a7 100644 --- a/mobile/src/settings/native-notification-settings-operations.ts +++ b/mobile/src/settings/native-notification-settings-operations.ts @@ -3,7 +3,8 @@ import { ensureNotificationPermissions, getNotificationPermissionState } from '../notifications/notification-permissions' -import { loadPushNotificationsEnabled, savePushNotificationsEnabled } from '../storage/preferences' +import { loadPushNotificationsEnabled } from '../storage/preferences' +import { setRemotePushEnabled } from '../notifications/push-registration' import type { NotificationSettingsOperations } from './notification-settings-operations' export const nativeNotificationSettingsOperations: NotificationSettingsOperations = { @@ -15,7 +16,7 @@ export const nativeNotificationSettingsOperations: NotificationSettingsOperation }, async preference(enabled) { if (enabled !== undefined) { - await savePushNotificationsEnabled(enabled) + await setRemotePushEnabled(enabled) } return { enabled: await loadPushNotificationsEnabled() } }, diff --git a/mobile/src/settings/notification-display-test.test.tsx b/mobile/src/settings/notification-display-test.test.tsx new file mode 100644 index 00000000000..ea5128c7fb5 --- /dev/null +++ b/mobile/src/settings/notification-display-test.test.tsx @@ -0,0 +1,88 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { NotificationDisplayTest } from './notification-display-test' + +const mocks = vi.hoisted(() => ({ + loadHosts: vi.fn(), + clients: [] as { state: string; client: { sendRequest: ReturnType } }[] +})) +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + Text: 'Text', + View: 'View', + StyleSheet: { create: (value: unknown) => value, absoluteFillObject: {} } +})) +vi.mock('../transport/host-store', () => ({ loadHostCatalog: mocks.loadHosts })) +vi.mock('../transport/use-all-host-clients', () => ({ useAllHostClients: () => mocks.clients })) + +let renderer: ReactTestRenderer +beforeEach(() => { + mocks.loadHosts.mockReset().mockResolvedValue([{ id: 'first' }, { id: 'second' }]) + mocks.clients = [] +}) +afterEach(() => act(() => renderer?.unmount())) + +async function send() { + await act(async () => { + renderer = create(createElement(NotificationDisplayTest, { onTroubleshoot: vi.fn() })) + }) + await act(async () => renderer.root.findAllByType('Pressable')[0].props.onPress()) +} + +it.each([ + { ok: false, error: { code: 'method_not_found' } }, + { ok: false, error: { code: 'forbidden' } }, + { ok: true, result: { accepted: false, reason: 'not_registered' } } +])('tries the next desktop after a definitive non-delivery response %j', async (response) => { + const first = vi.fn().mockResolvedValue(response) + const second = vi.fn().mockResolvedValue({ ok: true, result: { accepted: true } }) + const third = vi.fn() + mocks.clients = [first, second, third].map((sendRequest) => ({ + state: 'connected', + client: { sendRequest } + })) + await send() + expect(second).toHaveBeenCalledExactlyOnceWith('notifications.testPush', null, { + timeoutMs: 20000, + failWhenDisconnected: true + }) + expect(third).not.toHaveBeenCalled() + expect(JSON.stringify(renderer.toJSON())).toContain('Accepted by Orca’s push service') +}) + +it('does not try another desktop after an uncertain transport failure', async () => { + const second = vi.fn() + mocks.clients = [ + { + state: 'connected', + client: { sendRequest: vi.fn().mockRejectedValue(new Error('timeout')) } + }, + { state: 'connected', client: { sendRequest: second } } + ] + await send() + expect(second).not.toHaveBeenCalled() + expect(JSON.stringify(renderer.toJSON())).toContain('timeout') +}) + +it('explains when every desktop needs registration or an update', async () => { + mocks.clients = [ + { ok: true, result: { accepted: false, reason: 'not_registered' } }, + { ok: false, error: { code: 'method_not_found' } } + ].map((response) => ({ + state: 'connected', + client: { sendRequest: vi.fn().mockResolvedValue(response) } + })) + await send() + expect(JSON.stringify(renderer.toJSON())).toContain('Reconnect to register this phone') +}) + +it.each([false, true])('explains missing pairing or connection (paired=%s)', async (paired) => { + if (!paired) { + mocks.loadHosts.mockResolvedValue([]) + } + await send() + expect(JSON.stringify(renderer.toJSON())).toContain( + paired ? 'Connect a desktop' : 'Pair a desktop' + ) +}) diff --git a/mobile/src/settings/notification-display-test.tsx b/mobile/src/settings/notification-display-test.tsx new file mode 100644 index 00000000000..f00c0af5625 --- /dev/null +++ b/mobile/src/settings/notification-display-test.tsx @@ -0,0 +1,125 @@ +import { useEffect, useRef, useState } from 'react' +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { useAllHostClients } from '../transport/use-all-host-clients' +import { loadHostCatalog } from '../transport/host-store' +import type { MobilePushTestResult } from '../../../src/shared/mobile-push-contract' +import { colors, spacing, typography } from '../theme/mobile-theme' + +export function NotificationDisplayTest({ onTroubleshoot }: { onTroubleshoot: () => void }) { + const busy = useRef(false) + const [hostIds, setHostIds] = useState([]) + const [sending, setSending] = useState(false) + const [message, setMessage] = useState(null) + const clients = useAllHostClients(hostIds) + useEffect(() => { + void loadHostCatalog() + .then((hosts) => setHostIds(hosts.map((host) => host.id))) + .catch(() => setMessage('Could not load paired desktops.')) + }, []) + const run = async () => { + if (busy.current) { + return + } + busy.current = true + setSending(true) + setMessage(null) + try { + if (hostIds.length === 0) { + throw new Error('Pair a desktop and try again.') + } + const connected = clients.filter((entry) => entry.state === 'connected') + if (connected.length === 0) { + throw new Error('Connect a desktop and try again.') + } + let unavailable = 'Update your desktop to run this test.' + for (const { client } of connected) { + const response = await client.sendRequest('notifications.testPush', null, { + timeoutMs: 20000, + failWhenDisconnected: true + }) + if (!response.ok) { + const code = response.error?.code + if (code === 'forbidden' || code === 'method_not_found') { + continue + } + throw new Error('Could not reach the desktop. Try again.') + } + const result = response.result as MobilePushTestResult + if (result?.accepted) { + setMessage('Accepted by Orca’s push service. Check for the notification.') + return + } + if (result?.reason === 'not_registered') { + unavailable = 'Reconnect to register this phone for notifications.' + continue + } + throw new Error( + result?.reason === 'rate_limited' + ? 'Too many notifications. Try again later.' + : 'Could not send through Orca’s push service. Try again.' + ) + } + throw new Error(unavailable) + } catch (error) { + setMessage(error instanceof Error ? error.message : 'Could not send push test.') + } finally { + busy.current = false + setSending(false) + } + } + return ( + + Having trouble receiving alerts? + Send a test through Orca’s push service. + [styles.button, pressed && styles.pressed]} + onPress={() => void run()} + > + + + Send test notification + + + + {sending ? 'Sending…' : 'Send test notification'} + + + + + + Troubleshooting + + {message && ( + + {message} + + )} + + ) +} +const styles = StyleSheet.create({ + container: { marginTop: spacing.xl, gap: spacing.sm }, + label: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' }, + detail: { color: colors.textMuted, fontSize: typography.metaSize, lineHeight: 18 }, + button: { + alignSelf: 'flex-start', + backgroundColor: colors.bgRaised, + borderRadius: 8, + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md + }, + sizingLabel: { opacity: 0 }, + buttonLabel: { ...StyleSheet.absoluteFillObject, alignItems: 'center', justifyContent: 'center' }, + troubleshootLink: { alignSelf: 'flex-start', paddingVertical: spacing.sm }, + linkText: { + color: colors.textSecondary, + fontSize: typography.metaSize, + textDecorationLine: 'underline' + }, + pressed: { opacity: 0.6 }, + buttonText: { color: colors.textPrimary, fontSize: typography.metaSize, fontWeight: '600' } +}) diff --git a/mobile/src/settings/notification-settings-screen.tsx b/mobile/src/settings/notification-settings-screen.tsx index 7da9be8a813..a0b8251a620 100644 --- a/mobile/src/settings/notification-settings-screen.tsx +++ b/mobile/src/settings/notification-settings-screen.tsx @@ -1,5 +1,5 @@ -import { useState, useCallback, useEffect } from 'react' -import { AppState, View, Text, StyleSheet, Pressable, Switch } from 'react-native' +import { useState, useCallback, useEffect, type ReactNode } from 'react' +import { AppState, View, Text, StyleSheet, Pressable, Switch, ScrollView } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useFocusEffect } from 'expo-router' import type { NotificationSettingsOperations } from './notification-settings-operations' @@ -16,12 +16,17 @@ const DEFAULT_PERMISSION_STATE: NotificationPermissionState = { export default function NotificationsScreen({ operations, - onBack + onBack, + description, + children }: { operations: NotificationSettingsOperations onBack: () => void + description?: string + children?: (enabled: boolean) => ReactNode }) { const insets = useSafeAreaInsets() + const [saving, setSaving] = useState(false) const [error, setError] = useState(null) const [pushEnabled, setPushEnabled] = useState(false) const [permissionState, setPermissionState] = useState(DEFAULT_PERMISSION_STATE) @@ -57,6 +62,7 @@ export default function NotificationsScreen({ const togglePush = async (value: boolean) => { setError(null) + setSaving(true) try { const permission = await operations.permission(value) setPermissionState(permission) @@ -64,6 +70,8 @@ export default function NotificationsScreen({ setPushEnabled(saved.enabled) } catch { setError('Could not save notification settings. Try again.') + } finally { + setSaving(false) } } @@ -71,10 +79,17 @@ export default function NotificationsScreen({ const notificationsBlocked = permissionState.status === 'denied' const hint = notificationsBlocked ? 'Notifications are disabled in system settings.' - : 'Get notified on this device when an agent needs your input or finishes a task.' + : (description ?? + 'Get notified on this device when an agent needs your input or finishes a task.') return ( - + - Agent notifications + Enable notifications void togglePush(v)} trackColor={{ false: colors.bgRaised, true: colors.textSecondary }} thumbColor={colors.textPrimary} @@ -123,7 +138,8 @@ export default function NotificationsScreen({ )} - + {children?.(switchEnabled && !saving)} + ) } diff --git a/mobile/src/storage/preferences.test.ts b/mobile/src/storage/preferences.test.ts index b636ea12d3e..c8cfb54863a 100644 --- a/mobile/src/storage/preferences.test.ts +++ b/mobile/src/storage/preferences.test.ts @@ -278,8 +278,18 @@ describe('push notification preference', () => { vi.mocked(AsyncStorage.setItem).mockReset() }) + it.each(['true', 'false'])('requires fresh consent for legacy choice %s', async (legacy) => { + vi.mocked(AsyncStorage.getItem).mockImplementation(async (key) => + key === 'orca:pushNotificationsEnabled' ? legacy : null + ) + await expect(readPushNotificationsPreference()).resolves.toEqual({ value: null, loaded: true }) + await expect(loadPushNotificationsEnabled()).resolves.toBe(false) + }) + it('distinguishes an unset preference from an explicit disabled choice', async () => { - vi.mocked(AsyncStorage.getItem).mockResolvedValue(null) + vi.mocked(AsyncStorage.getItem).mockImplementation(async (key) => + key === 'orca:remotePushEnabled' ? 'true' : null + ) await expect(readPushNotificationsPreference()).resolves.toEqual({ value: null, loaded: true @@ -303,12 +313,17 @@ describe('push notification preference', () => { await expect(loadPushNotificationsEnabled()).resolves.toBe(false) }) - it('persists the onboarding decision in the existing mobile toggle', async () => { - await savePushNotificationsEnabled(true) - expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:pushNotificationsEnabled', 'true') - - await savePushNotificationsEnabled(false) - expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:pushNotificationsEnabled', 'false') + it('persists and reloads master consent', async () => { + const storage = new Map() + vi.mocked(AsyncStorage.getItem).mockImplementation(async (key) => storage.get(key) ?? null) + vi.mocked(AsyncStorage.setItem).mockImplementation(async (key, value) => { + storage.set(key, value) + }) + for (const enabled of [true, false]) { + await savePushNotificationsEnabled(enabled) + await expect(loadPushNotificationsEnabled()).resolves.toBe(enabled) + } + expect([...storage]).toEqual([['orca:pushServiceNotificationsEnabled', 'false']]) }) }) diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts index 5173ac5bc8a..57420469609 100644 --- a/mobile/src/storage/preferences.ts +++ b/mobile/src/storage/preferences.ts @@ -1,7 +1,8 @@ import AsyncStorage from '@react-native-async-storage/async-storage' const PINS_PREFIX = 'orca:pins:' -const NOTIF_KEY = 'orca:pushNotificationsEnabled' +// Consent to the push service is separate from the old socket notification choice. +const NOTIF_KEY = 'orca:pushServiceNotificationsEnabled' export type PushNotificationsPreference = { readonly value: boolean | null @@ -30,6 +31,43 @@ export async function savePushNotificationsEnabled(enabled: boolean): Promise { + try { + const raw = await AsyncStorage.getItem(REMOTE_PUSH_HOST_REGISTRATIONS_KEY) + if (!raw) { + return EMPTY_REMOTE_PUSH_HOST_REGISTRATIONS + } + const parsed = JSON.parse(raw) as Record + return { + registeredHostIds: stringArray(parsed.registeredHostIds), + pendingUnregisterHostIds: stringArray(parsed.pendingUnregisterHostIds) + } + } catch { + return EMPTY_REMOTE_PUSH_HOST_REGISTRATIONS + } +} + +export async function saveRemotePushHostRegistrations( + value: RemotePushHostRegistrations +): Promise { + await AsyncStorage.setItem(REMOTE_PUSH_HOST_REGISTRATIONS_KEY, JSON.stringify(value)) +} + const TEXT_SCALE_KEY = 'orca:terminalTextScale' // Why: the mobile terminal fits the desktop's full column count to the phone diff --git a/mobile/src/transport/client-context.test.ts b/mobile/src/transport/client-context.test.ts index 56b227bdff7..f1af4b87b36 100644 --- a/mobile/src/transport/client-context.test.ts +++ b/mobile/src/transport/client-context.test.ts @@ -5,6 +5,9 @@ import type { ConnectionState } from './types' import type { RpcClient } from './rpc-client' import type { MobileConnectionPath } from './stable-logical-rpc-client' +const push = vi.hoisted(() => ({ attach: vi.fn(), detach: vi.fn() })) +vi.mock('../notifications/push-registration', () => ({ attachPushRegistration: push.attach })) + const connectMock = vi.fn() const loadHostsMock = vi.fn() @@ -141,6 +144,8 @@ async function renderHarness(hostId: string): Promise { } beforeEach(() => { + push.attach.mockReset().mockReturnValue(push.detach) + push.detach.mockReset() connectMock.mockReset() loadHostsMock.mockReset() }) @@ -707,3 +712,33 @@ describe('useAllHostClients', () => { } }) }) + +it('owns push registration for a paired host without mounting the home screen', async () => { + const client = makeFakeClient('handshaking') + connectMock.mockReturnValue(client) + loadHostsMock.mockResolvedValue([HOST]) + const harness = await renderHarness(HOST.id) + expect(push.attach).not.toHaveBeenCalled() + await act(async () => client.emitState('connected')) + expect(push.attach).toHaveBeenCalledExactlyOnceWith(HOST.id, client) + await act(async () => client.emitState('connected')) + expect(push.attach).toHaveBeenCalledOnce() + await act(async () => client.emitState('disconnected')) + expect(push.detach).toHaveBeenCalledOnce() + await act(async () => client.emitState('connected')) + expect(push.attach).toHaveBeenCalledTimes(2) + await act(async () => harness.unmount()) + expect(push.detach).toHaveBeenCalledTimes(2) +}) + +it('registers an already authenticated host and detaches on explicit disconnect', async () => { + const client = makeFakeClient('connected') + connectMock.mockReturnValue(client) + loadHostsMock.mockResolvedValue([HOST]) + const harness = await renderHarness(HOST.id) + expect(push.attach).toHaveBeenCalledExactlyOnceWith(HOST.id, client) + await act(async () => harness.disconnectHost(HOST.id)) + expect(push.detach).toHaveBeenCalledOnce() + await act(async () => harness.unmount()) + expect(push.detach).toHaveBeenCalledOnce() +}) diff --git a/mobile/src/transport/host-entry-opener.ts b/mobile/src/transport/host-entry-opener.ts index 03d6363c7c7..6a22d29bf5e 100644 --- a/mobile/src/transport/host-entry-opener.ts +++ b/mobile/src/transport/host-entry-opener.ts @@ -1,3 +1,4 @@ +import { attachPushRegistration } from '../notifications/push-registration' import { connectionLogStore, recordConnectionClientSessionStart @@ -113,11 +114,21 @@ export async function openHostClientEntry( client.close() return state.store.get(hostId) ?? null } - const unsubState = client.onStateChange((next) => { + let detachPushRegistration: (() => void) | null = null + const syncPushRegistration = (next: ConnectionState): void => { + if (next === 'connected') { + detachPushRegistration ??= attachPushRegistration(hostId, client) + } else { + detachPushRegistration?.() + detachPushRegistration = null + } + } + const unsubscribeState = client.onStateChange((next) => { const current = state.store.get(hostId) if (!current) { return } + syncPushRegistration(next) current.state = next state.notifyHostState(hostId, next) }) @@ -134,11 +145,16 @@ export async function openHostClientEntry( clientId: host.deviceToken, state: client.getState(), refCount: state.pendingAcquisitions.get(hostId) ?? 0, - unsubState, + unsubState: () => { + unsubscribeState() + detachPushRegistration?.() + detachPushRegistration = null + }, unsubConnectionPath } state.pendingAcquisitions.delete(hostId) state.store.set(hostId, entry) + syncPushRegistration(entry.state) settle() const priorFailureCount = state.retryScheduler.recordSuccess(hostId) if (priorFailureCount > 0) { diff --git a/mobile/src/transport/host-open-recovery.test.tsx b/mobile/src/transport/host-open-recovery.test.tsx index 70ebc131791..90e79c13976 100644 --- a/mobile/src/transport/host-open-recovery.test.tsx +++ b/mobile/src/transport/host-open-recovery.test.tsx @@ -1,3 +1,6 @@ +vi.mock('../notifications/push-registration', () => ({ + attachPushRegistration: () => () => {} +})) import { createElement, type ReactElement } from 'react' import { act, create } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/mobile/src/transport/host-removal-lifecycle.test.ts b/mobile/src/transport/host-removal-lifecycle.test.ts index 6c96ef1c446..08313ff3780 100644 --- a/mobile/src/transport/host-removal-lifecycle.test.ts +++ b/mobile/src/transport/host-removal-lifecycle.test.ts @@ -1,91 +1,50 @@ 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 })) +const unregisterPushMock = vi.hoisted(() => vi.fn(async () => vi.fn())) vi.mock('./host-store', () => ({ removeHost: (hostId: string) => removeHostMock(hostId) })) +vi.mock('../notifications/push-registration', () => ({ + unregisterPushForRemovedHost: (hostId: string) => unregisterPushMock(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() + unregisterPushMock.mockClear() }) it('closes the client only after metadata removal commits', async () => { let commitRemoval: (() => void) | null = null - removeHostMock.mockReturnValue( - new Promise((resolve) => { - commitRemoval = resolve - }) - ) + removeHostMock.mockReturnValue(new Promise((resolve) => (commitRemoval = resolve))) const closeHostClient = vi.fn() - const removal = removeHostAndCloseClient('host-1', closeHostClient) expect(closeHostClient).not.toHaveBeenCalled() commitRemoval?.() await removal - expect(closeHostClient).toHaveBeenCalledWith('host-1') }) it('keeps the client open when metadata removal fails', async () => { removeHostMock.mockRejectedValue(new Error('storage unavailable')) const closeHostClient = vi.fn() - await expect(removeHostAndCloseClient('host-1', closeHostClient)).rejects.toThrow( 'storage unavailable' ) 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. + it('drops the gateway push registration before the credentials it needs are gone', async () => { 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') + expect(unregisterPushMock).toHaveBeenCalledWith('host-1') + expect(unregisterPushMock.mock.invocationCallOrder[0]).toBeLessThan( + removeHostMock.mock.invocationCallOrder[0] + ) }) }) diff --git a/mobile/src/transport/host-removal-lifecycle.ts b/mobile/src/transport/host-removal-lifecycle.ts index cd0a09cb67e..159c6bca59e 100644 --- a/mobile/src/transport/host-removal-lifecycle.ts +++ b/mobile/src/transport/host-removal-lifecycle.ts @@ -1,20 +1,20 @@ -import { - clearWatermark, - forgetHostNotificationSession -} from '../notifications/notification-reconnect-catchup' +import { unregisterPushForRemovedHost } from '../notifications/push-registration' import { removeHost } from './host-store' export async function removeHostAndCloseClient( hostId: string, forgetHostClient: (hostId: string) => void ): Promise { + // Why before removeHost: the unregister needs the still-authenticated client, and + // the desktop's own revoke path covers the case where this call cannot land. + const restorePushRegistration = await unregisterPushForRemovedHost(hostId) // Why: closing before the metadata commit can strand a still-paired host on // storage failure; closing immediately after success prevents socket leaks. - await removeHost(hostId) + try { + await removeHost(hostId) + } catch (error) { + restorePushRegistration() + throw error + } forgetHostClient(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) } diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 10f586d2780..38941483c4d 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -1,19 +1,11 @@ import type { BrowserScreencastFrame } from './browser-screencast-protocol' import { DirectRpcClient } from './direct-rpc-client' -import type { - ConnectionLogSink, - ConnectionState, - ForegroundNudgeReason, - RpcResponse -} from './types' +import type { ConnectionLogSink, ConnectionState, ForegroundNudgeReason } from './types' +import type { UnvalidatedRpcRequestPort } from './unvalidated-rpc-request-port' -export type SendRequestOptions = { - timeoutMs?: number - /** Include the connect wait in the caller's timeout budget. */ - budgetSpansConnect?: boolean - /** Reject instead of replaying the request after reconnect. */ - failWhenDisconnected?: boolean -} +// Re-export shim: the options type moved to the port module with the sender it belongs to, +// and re-exporting is what keeps that move from touching every importer. +export type { SendRequestOptions } from './unvalidated-rpc-request-port' type SubscribeOptions = { onBinaryFrame?: (frame: BrowserScreencastFrame) => void @@ -21,12 +13,9 @@ type SubscribeOptions = { type StreamingListener = (result: unknown) => void -export type RpcClient = { - sendRequest: ( - method: string, - params?: unknown, - options?: SendRequestOptions - ) => Promise +// Still structurally carries the raw sender, so holding a client is still holding the port — +// which is why the boundary is inventoried rather than merely declared. +export type RpcClient = UnvalidatedRpcRequestPort & { subscribe: ( method: string, params: unknown, diff --git a/mobile/src/transport/rpc-incompatible-reply-error.ts b/mobile/src/transport/rpc-incompatible-reply-error.ts new file mode 100644 index 00000000000..026732ca3b5 --- /dev/null +++ b/mobile/src/transport/rpc-incompatible-reply-error.ts @@ -0,0 +1,27 @@ +import type { RpcDecodeIssue } from './rpc-operation-contract' + +const INCOMPATIBLE_REPLY_MESSAGE_PREFIX = 'incompatible_reply: ' + +// Why: a reply the operation's reader cannot read says nothing about what the host did. +// On a mutation it is NOT evidence the mutation failed and authorizes no retry — only a +// host-negotiated idempotency capability inside its dedupe window does (see +// tasks/worktree-create-retry.ts). So this error is deliberately neither marked +// delivery-unknown nor shaped like the cutover error the retry loops replay on. +export class RpcIncompatibleReplyError extends Error { + constructor( + readonly operationName: string, + readonly method: string, + readonly issues: readonly RpcDecodeIssue[] + ) { + super(`${INCOMPATIBLE_REPLY_MESSAGE_PREFIX}${operationName} (${method})`) + } +} + +// Why: instanceof can miss across bundle copies, so also match by message, mirroring +// isLogicalClientCutoverError. +export function isRpcIncompatibleReplyError(error: unknown): boolean { + return ( + error instanceof RpcIncompatibleReplyError || + (error instanceof Error && error.message.startsWith(INCOMPATIBLE_REPLY_MESSAGE_PREFIX)) + ) +} diff --git a/mobile/src/transport/rpc-operation-barrier.test.ts b/mobile/src/transport/rpc-operation-barrier.test.ts new file mode 100644 index 00000000000..43c74198b44 --- /dev/null +++ b/mobile/src/transport/rpc-operation-barrier.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' +import type { RpcResponse } from './types' +import { FakeSession } from './mobile-endpoint-supervisor-test-fakes' +import { isRpcDeliveryUnknown, markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' +import { interpretAtRpcBarrier, startRpcOperation } from './rpc-operation' +import { + rpcRefusal, + rpcSuccess, + terminalListAtBarrier, + workspaceListAtBarrier, + worktreePsProbeAtBarrier +} from './rpc-operation-test-families' + +const rows = { worktrees: [{ id: 'w1' }] } + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +function replying(response: RpcResponse): FakeSession { + const session = new FakeSession('connected') + session.sendRequest.mockResolvedValue(response) + return session +} + +function settleAfter(milliseconds: number): Promise<'still waiting'> { + return new Promise((resolve) => setTimeout(() => resolve('still waiting'), milliseconds)) +} + +describe('the post-barrier combinator', () => { + it('starts every request before anything is awaited', () => { + const first = replying(rpcSuccess(rows)) + const second = replying(rpcSuccess({ terminals: [] })) + + startRpcOperation(first, workspaceListAtBarrier, {}) + startRpcOperation(second, terminalListAtBarrier, {}) + + expect(first.sendRequest).toHaveBeenCalledTimes(1) + expect(second.sendRequest).toHaveBeenCalledTimes(1) + }) + + it('yields one verdict per operation, in declared order', async () => { + const verdicts = await interpretAtRpcBarrier([ + startRpcOperation(replying(rpcSuccess(rows)), workspaceListAtBarrier, {}), + startRpcOperation(replying(rpcSuccess({ terminals: [] })), terminalListAtBarrier, {}), + startRpcOperation(replying(rpcSuccess(rows)), worktreePsProbeAtBarrier, {}) + ]) + + expect(verdicts).toEqual([rows, { terminals: [] }, false]) + }) + + // The bug class this exists to remove: whichever peer lost the race used to decide which + // error the user saw. Here the second request fails first in time and the first one refuses + // afterwards, and the declaration still decides. + it('interprets in declared order rather than completion order', async () => { + const lateRefusal = deferred() + const refusing = new FakeSession('connected') + refusing.sendRequest.mockReturnValue(lateRefusal.promise) + const dropped = new FakeSession('connected') + dropped.sendRequest.mockRejectedValue(new Error('socket closed first')) + + const barrier = interpretAtRpcBarrier([ + startRpcOperation(refusing, workspaceListAtBarrier, {}), + startRpcOperation(dropped, terminalListAtBarrier, {}) + ]) + lateRefusal.resolve(rpcRefusal('method_not_found', 'no such method')) + + await expect(barrier).rejects.toThrow('method_not_found: no such method') + }) + + it('keeps the middle operation error when a later one also fails', async () => { + const barrier = interpretAtRpcBarrier([ + startRpcOperation(replying(rpcSuccess(rows)), workspaceListAtBarrier, {}), + startRpcOperation( + replying(rpcRefusal('conflict', 'middle refused')), + workspaceListAtBarrier, + {} + ), + startRpcOperation( + replying(rpcRefusal('runtime_error', 'later refused')), + workspaceListAtBarrier, + {} + ) + ]) + + await expect(barrier).rejects.toThrow('conflict: middle refused') + }) + + it('does not interpret until every raw request has settled', async () => { + const refusing = replying(rpcRefusal('runtime_error', 'boom')) + const pending = deferred() + const stalled = new FakeSession('connected') + stalled.sendRequest.mockReturnValue(pending.promise) + + const barrier = interpretAtRpcBarrier([ + startRpcOperation(refusing, workspaceListAtBarrier, {}), + startRpcOperation(stalled, terminalListAtBarrier, {}) + ]) + const raced = await Promise.race([ + barrier.then( + () => 'resolved' as const, + () => 'rejected' as const + ), + settleAfter(50) + ]) + expect(raced).toBe('still waiting') + + pending.resolve(rpcSuccess({ terminals: [] })) + await expect(barrier).rejects.toThrow('runtime_error: boom') + }) + + it('rethrows a captured transport rejection as the original error object', async () => { + const error = markRpcDeliveryUnknown(new Error('socket closed before response')) + const dropped = new FakeSession('connected') + dropped.sendRequest.mockRejectedValue(error) + + const caught = await interpretAtRpcBarrier([ + startRpcOperation(replying(rpcSuccess(rows)), workspaceListAtBarrier, {}), + startRpcOperation(dropped, terminalListAtBarrier, {}) + ]).catch((thrown: unknown) => thrown) + + expect(caught).toBe(error) + expect(isRpcDeliveryUnknown(caught)).toBe(true) + }) + + it('applies the policy each family declared, at the barrier', async () => { + const verdicts = await interpretAtRpcBarrier([ + startRpcOperation(replying(rpcRefusal('runtime_error')), terminalListAtBarrier, {}), + startRpcOperation(replying(rpcRefusal('method_not_found')), worktreePsProbeAtBarrier, {}) + ]) + + expect(verdicts).toEqual([null, true]) + }) +}) diff --git a/mobile/src/transport/rpc-operation-cast-fence.test.ts b/mobile/src/transport/rpc-operation-cast-fence.test.ts new file mode 100644 index 00000000000..83d76eb87ab --- /dev/null +++ b/mobile/src/transport/rpc-operation-cast-fence.test.ts @@ -0,0 +1,256 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { extname, join, relative, resolve } from 'node:path' +import ts from 'typescript' +import { describe, expect, it } from 'vitest' + +/** + * Bans the escapes that would make the typed boundary decorative. + * + * An operation's whole claim is that a reply arrives as a declared type because a reader + * decoded it. `as`, `any` and a `@ts-` suppression each produce the same declared type without + * the decode, so one of them anywhere in an operation implementation buys back exactly the + * drift the contract removed — and it buys it silently, since the code still compiles and the + * types still read as validated. + * + * The fenced region includes the operation API, contract and result-reader factory, plus + * non-test files importing them and files that re-export a + * file that is (transitively). Step 4's operation modules therefore land inside the fence the + * moment they are written, with nothing to remember. + * + * What this does NOT catch, all accepted: + * - A lying reader. `z.unknown()` or a schema looser than the reply decodes anything, and no + * syntax check can tell a permissive schema from a wrong one. + * - Structural laundering: a helper in an unfenced module that returns the wrong type + * honestly, which the operation then consumes without a cast. + * - `!` non-null assertions, and the widening that an untyped intermediate variable gives + * you for free. + * - A screen. Screens are outside the region by design until they hold an operation; the + * raw-port inventory is what governs them. + */ + +const mobileRoot = fileURLToPath(new URL('../..', import.meta.url)) +const scannedRoots = ['app', 'src'].map((directory) => join(mobileRoot, directory)) +const sourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx']) +const transportRoot = join(mobileRoot, 'src', 'transport') + +/** Importing any of these is what makes a file an operation implementation. */ +const REGION_SEEDS = new Set( + ['rpc-operation', 'rpc-operation-contract', 'rpc-operation-result-reader'].map((name) => + join(transportRoot, name) + ) +) + +export type RpcOperationEscape = 'assertion' | 'any' | 'suppression' + +type CastFenceException = { + readonly file: string + readonly allows: readonly RpcOperationEscape[] +} + +/** + * The modules that own the `unknown` → declared-type transition, so the erasure has to land + * somewhere. Held as data, per escape kind, so an exception cannot quietly widen into the + * others. Every entry is also checked for staleness. + */ +const CAST_FENCE_EXCEPTIONS: readonly CastFenceException[] = [ + // The interpreter. Its casts re-apply type parameters that `AnyRpcOperation` erased on the + // way in; none of them invents a shape the reader did not already produce. + { file: 'src/transport/rpc-operation.ts', allows: ['assertion'] }, + // The reader factory. `safeParse` returns the schema's own output type as `unknown`. + { file: 'src/transport/rpc-operation-result-reader.ts', allows: ['assertion'] }, + // Nothing but suppressions: every directive in it is an assertion that tsc still rejects + // the thing above it, which is the compile fence's entire mechanism. + { file: 'src/transport/rpc-operation-compile-fence.ts', allows: ['suppression'] } +] + +// Text, not AST: a suppression is a comment, and comments are not nodes. A directive spelled +// inside a string literal therefore reads as one — which fails closed. +const SUPPRESSION = /@ts-(?:expect-error|ignore|nocheck)\b/ + +function sourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name) + if (entry.isDirectory()) { + return entry.name === 'node_modules' ? [] : sourceFiles(path) + } + return [path] + }) +} + +function parse(path: string, source: string): ts.SourceFile { + const extension = extname(path) + return ts.createSourceFile( + path, + source, + ts.ScriptTarget.Latest, + true, + extension === '.tsx' || extension === '.jsx' ? ts.ScriptKind.TSX : ts.ScriptKind.TS + ) +} + +function resolvedSpecifier(path: string, node: ts.Node | undefined): string | null { + if (!node || !ts.isStringLiteral(node) || !node.text.startsWith('.')) { + return null + } + return resolve(path, '..', node.text) +} + +/** `as const` narrows a literal; it declares nothing the value was not already. */ +function isConstAssertion(node: ts.AsExpression): boolean { + return ( + ts.isTypeReferenceNode(node.type) && + ts.isIdentifier(node.type.typeName) && + node.type.typeName.text === 'const' + ) +} + +export function rpcOperationEscapes(path: string, source: string): RpcOperationEscape[] { + const found: RpcOperationEscape[] = [] + const visit = (node: ts.Node): void => { + if ( + (ts.isAsExpression(node) && !isConstAssertion(node)) || + ts.isTypeAssertionExpression(node) + ) { + found.push('assertion') + } + if (node.kind === ts.SyntaxKind.AnyKeyword) { + found.push('any') + } + ts.forEachChild(node, visit) + } + visit(parse(path, source)) + if (SUPPRESSION.test(source)) { + found.push('suppression') + } + return [...new Set(found)].sort() +} + +/** Imports and re-exports that make the importer part of the operation region. */ +function moduleEdges(path: string, source: string): { imports: string[]; reExports: string[] } { + const imports: string[] = [] + const reExports: string[] = [] + for (const statement of parse(path, source).statements) { + if (ts.isImportDeclaration(statement)) { + const target = resolvedSpecifier(path, statement.moduleSpecifier) + if (target) { + imports.push(target) + } + continue + } + if (ts.isExportDeclaration(statement) && statement.moduleSpecifier) { + const target = resolvedSpecifier(path, statement.moduleSpecifier) + if (target) { + imports.push(target) + reExports.push(target) + } + } + } + return { imports, reExports } +} + +const scanned = scannedRoots + .flatMap(sourceFiles) + .filter((path) => sourceExtensions.has(extname(path))) + .filter((path) => !/\.test\.tsx?$/.test(path)) + +const sources = new Map(scanned.map((path) => [path, readFileSync(path, 'utf8')] as const)) +const edges = new Map([...sources].map(([path, source]) => [path, moduleEdges(path, source)])) + +/** Modules are keyed without their extension, the way a relative specifier resolves. */ +function moduleKey(path: string): string { + return path.replace(/\.[jt]sx?$/, '') +} + +const region = new Set(scanned.filter((path) => REGION_SEEDS.has(moduleKey(path)))) +for (const [path, { imports }] of edges) { + if (imports.some((target) => REGION_SEEDS.has(target))) { + region.add(path) + } +} +// Fixpoint over re-export edges: a barrel that re-exports an operation module is in the fence +// too, which is where a cast would otherwise sit unwatched between definition and screen. +for (let changed = true; changed;) { + changed = false + const members = new Set([...region].map(moduleKey)) + for (const [path, { reExports }] of edges) { + if (!region.has(path) && reExports.some((target) => members.has(target))) { + region.add(path) + changed = true + } + } +} + +const relativeRegion = [...region].map((path) => + relative(mobileRoot, path).split(/[/\\]/).join('/') +) + +describe('RPC operation cast fence', () => { + const probe = join(mobileRoot, 'src', 'transport', 'probe.ts') + + it('recognizes each escape and leaves honest code alone', () => { + expect(rpcOperationEscapes(probe, 'const v = raw as WorkspaceRows')).toEqual(['assertion']) + expect(rpcOperationEscapes(probe, 'const v = raw as unknown as WorkspaceRows')).toEqual([ + 'assertion' + ]) + expect(rpcOperationEscapes(probe, 'const v: any = raw')).toEqual(['any']) + expect(rpcOperationEscapes(probe, 'function f(raw: any) {}')).toEqual(['any']) + expect(rpcOperationEscapes(probe, 'const v = raw as any')).toEqual(['any', 'assertion']) + expect(rpcOperationEscapes(probe, '// @ts-expect-error\nconst v = raw')).toEqual([ + 'suppression' + ]) + expect(rpcOperationEscapes(probe, '// @ts-ignore\nconst v = raw')).toEqual(['suppression']) + expect(rpcOperationEscapes(probe, "const v = ['a'] as const")).toEqual([]) + expect(rpcOperationEscapes(probe, 'const v = read(raw)')).toEqual([]) + expect(rpcOperationEscapes(probe, 'const v = raw satisfies WorkspaceRows')).toEqual([]) + expect(rpcOperationEscapes(probe, 'const v = value!')).toEqual([]) + }) + + it('puts every operation module in the fenced region', () => { + for (const file of [ + 'src/transport/rpc-operation.ts', + 'src/transport/rpc-operation-contract.ts', + 'src/transport/rpc-operation-test-families.ts', + 'src/transport/rpc-operation-compile-fence.ts', + 'src/transport/rpc-operation-result-reader.ts', + 'src/transport/rpc-incompatible-reply-error.ts' + ]) { + expect(relativeRegion, `${file} must be fenced`).toContain(file) + } + // A screen that only holds a client is governed by the raw-port inventory, not by this. + expect(relativeRegion).not.toContain('src/transport/rpc-client.ts') + }) + + it('has no operation module casting, widening or suppressing its way to a type', () => { + const allowed = new Map(CAST_FENCE_EXCEPTIONS.map((entry) => [entry.file, entry.allows])) + const offenders = [...region] + .map((path) => { + const file = relative(mobileRoot, path).split(/[/\\]/).join('/') + const escapes = rpcOperationEscapes(path, sources.get(path) ?? '') + const permitted = allowed.get(file) ?? [] + return { file, escapes: escapes.filter((escape) => !permitted.includes(escape)) } + }) + .filter((entry) => entry.escapes.length > 0) + .map((entry) => `${entry.file}: ${entry.escapes.join(', ')}`) + .sort() + + expect( + offenders, + 'Decode the reply with a reader instead. An operation that asserts its own result type is not typed.' + ).toEqual([]) + }) + + it('has no stale cast-fence exception', () => { + const stale = CAST_FENCE_EXCEPTIONS.flatMap((entry) => { + const path = join(mobileRoot, entry.file) + if (!region.has(path)) { + return [`${entry.file}: no longer in the fenced region`] + } + const escapes = rpcOperationEscapes(path, sources.get(path) ?? '') + return entry.allows + .filter((escape) => !escapes.includes(escape)) + .map((escape) => `${entry.file}: no longer uses '${escape}'`) + }) + expect(stale, 'Narrow or delete the exception in rpc-operation-cast-fence.test.ts.').toEqual([]) + }) +}) diff --git a/mobile/src/transport/rpc-operation-compile-fence.ts b/mobile/src/transport/rpc-operation-compile-fence.ts new file mode 100644 index 00000000000..3188774743c --- /dev/null +++ b/mobile/src/transport/rpc-operation-compile-fence.ts @@ -0,0 +1,191 @@ +import type { RpcClient } from './rpc-client' +import type { RpcMethodName, RpcParams, RpcSendParams } from './rpc-params-contract' +import { defineRpcOperation, runRpcOperation, startRpcOperation } from './rpc-operation' +import { rpcResultVariants } from './rpc-operation-result-reader' +import { + workspaceListAtBarrier, + workspaceListOrNull, + workspaceRowsReader, + worktreePsProbe, + type WorkspaceRows +} from './rpc-operation-test-families' +import type { + CapabilityProbeRpcDefinition, + ObjectResultRpcDefinition, + RequireResultRpcDefinition, + RpcAcceptanceName, + RpcCompatibleReader, + RpcOperation +} from './rpc-operation-contract' + +// Why this file exists: the descriptor's whole point is that a call site cannot pick the +// acceptance policy, the interpretation barrier, or the send-side params for itself. Every +// expect-error directive below is that claim as an assertion — tsc fails on a directive that +// stops catching an error, so `pnpm --dir mobile typecheck` is the gate. Nothing here runs and +// no app code imports it. + +declare const client: RpcClient + +// @ts-expect-error a variant reader combinator must have at least one reader +const _fenceEmptyVariantReaders = rpcResultVariants([]) + +export const fenceProbeWithReader: CapabilityProbeRpcDefinition<'worktree.ps', 'on-settle'> = { + name: 'fence.probeWithReader', + method: 'worktree.ps', + acceptance: 'method-not-found-refusal', + barrier: 'on-settle', + // @ts-expect-error a refusal-code probe reads no payload, so it cannot carry a reader + read: workspaceRowsReader +} + +// @ts-expect-error 'require-result-or-throw' has no value to return without a reader +export const fenceDecodingWithoutReader: RequireResultRpcDefinition< + 'worktree.ps', + 'rows', + WorkspaceRows, + 'on-settle' +> = { + name: 'fence.decodingWithoutReader', + method: 'worktree.ps', + acceptance: 'require-result-or-throw', + barrier: 'on-settle' +} + +// @ts-expect-error the four policies in rpc-acceptance-policies.ts are the whole vocabulary +export const fenceInventedPolicy: RpcAcceptanceName = 'no-error-means-fine' + +// A reader for a payload no acceptance policy here admits, i.e. one belonging to some other +// family's shape. +const fenceTextReader: RpcCompatibleReader = (raw) => ({ + compatible: true, + variant: 'text', + value: raw, + salvage: { droppedPaths: [], droppedCount: 0 } +}) + +export const fenceObjectPolicyWrongReader: ObjectResultRpcDefinition< + 'worktree.ps', + 'text', + string, + 'on-settle' +> = { + name: 'fence.objectPolicyWrongReader', + method: 'worktree.ps', + acceptance: 'object-result-or-null', + barrier: 'on-settle', + // @ts-expect-error the policy admits a non-null object, not the string this reader expects + read: fenceTextReader +} + +export const fenceDefineRejectsMismatch = defineRpcOperation({ + name: 'fence.defineRejectsMismatch', + method: 'worktree.ps', + // @ts-expect-error no overload of defineRpcOperation pairs a probe with a payload reader + acceptance: 'method-not-found-refusal', + barrier: 'on-settle', + // @ts-expect-error ... and the reader it would need is exactly what the probe overload bans + read: workspaceRowsReader +}) + +// @ts-expect-error only generated catalog method names are addressable +export const fenceUnknownMethod: RpcMethodName = 'worktree.nope' + +// The send-side params type. z.output (what the handler receives) and z.input (what the +// coercing builders admit) are both wrong for a sender in opposite directions, so these pin +// the two failures a regression to either one would reintroduce. + +// `query` and `limit` carry .default(), so a sender may leave them out. Under z.output both +// read as required and this line stops compiling. +export const fenceOmitsDefaultedField: RpcSendParams<'files.searchPaths'> = { worktree: 'w' } + +export const fenceRejectsWrongFieldType: RpcSendParams<'files.searchPaths'> = { + // @ts-expect-error z.input of a z.unknown().transform builder admits any value; this does not + worktree: 42 +} + +// @ts-expect-error `worktree` has neither a default nor an optional marker +export const fenceKeepsRequiredField: RpcSendParams<'files.searchPaths'> = { query: 'x' } + +// Catalog-wide: anything a handler could have been handed is something a sender may write. +// A method that ever resolves tighter than its parsed shape lands in this union. +declare const fenceTighterThanParsed: { + [Method in RpcMethodName]: RpcParams extends RpcSendParams ? never : Method +}[RpcMethodName] & {} +export const fenceNoTighterMethod: never = fenceTighterThanParsed + +// z.input collapses every coercing builder to `unknown`. Only plugins.panelAction may be +// unknown, because its schema is literally z.unknown(). +declare const fenceUnknownParams: { + [Method in RpcMethodName]: unknown extends RpcSendParams ? Method : never +}[RpcMethodName] & {} +export const fenceOnlyDeclaredUnknown: 'plugins.panelAction' = fenceUnknownParams + +export async function fenceBarrierAndParams(): Promise { + await runRpcOperation( + client, + // @ts-expect-error this family interprets after all requests, so it has no on-settle run + workspaceListAtBarrier, + {} + ) + startRpcOperation( + client, + // @ts-expect-error an on-settle family must not be parked behind someone else's barrier + worktreePsProbe, + {} + ) + await runRpcOperation( + client, + workspaceListOrNull, + // @ts-expect-error worktree.ps takes a numeric limit + { limit: 'ten' } + ) +} + +export async function fenceVerdictTypes(): Promise { + // @ts-expect-error the probe's policy yields a boolean, not the other family's rows + const rows: WorkspaceRows = await runRpcOperation(client, worktreePsProbe, {}) + void rows +} + +// @ts-expect-error the public descriptor also requires decoding, even without the factory +export const fenceManualWithoutReader: RpcOperation< + 'worktree.ps', + 'require-result-or-throw', + 'rows', + WorkspaceRows, + 'on-settle' +> = { + name: 'fence.manual', + method: 'worktree.ps', + acceptance: 'require-result-or-throw', + barrier: 'on-settle' +} + +// @ts-expect-error widening the policy cannot disconnect it from its required reader +export const fenceBroadWithoutReader: RpcOperation< + 'worktree.ps', + RpcAcceptanceName, + 'rows', + WorkspaceRows, + 'on-settle' +> = { + name: 'fence.broad', + method: 'worktree.ps', + acceptance: 'require-result-or-throw', + barrier: 'on-settle', + read: undefined +} + +// @ts-expect-error object acceptance must decode, just like require-result acceptance +export const fenceObjectWithoutReader: RpcOperation< + 'worktree.ps', + 'object-result-or-null', + 'rows', + WorkspaceRows, + 'on-settle' +> = { + name: 'fence.object', + method: 'worktree.ps', + acceptance: 'object-result-or-null', + barrier: 'on-settle' +} diff --git a/mobile/src/transport/rpc-operation-contract.ts b/mobile/src/transport/rpc-operation-contract.ts new file mode 100644 index 00000000000..581506a97a7 --- /dev/null +++ b/mobile/src/transport/rpc-operation-contract.ts @@ -0,0 +1,155 @@ +import type { RpcMethodName } from './rpc-params-contract' +import type { RpcFailure, RpcResponse, RpcSuccess } from './types' + +// An operation descriptor fixes the method, the acceptance policy and the interpretation +// barrier at definition time. Per-call freedom over those three is what produced acceptance +// drift and settlement-order drift across mobile's RPC call sites, so none of them is a +// parameter of any send helper. + +/** One of the named policies in rpc-acceptance-policies.ts, chosen per operation family. */ +export type RpcAcceptanceName = + | 'require-result-or-throw' + | 'object-result-or-null' + | 'method-not-found-refusal' + | 'streaming-opener' + +/** Where a settled reply may become a value or a throw. */ +export type RpcInterpretationBarrier = 'on-settle' | 'after-all-requests' + +export type RpcDecodeIssue = { readonly path: string; readonly message: string } + +/** Bounded salvage diagnostics for a reply that decoded with parts dropped. */ +export type RpcSalvageReport = { + readonly droppedPaths: readonly string[] + readonly droppedCount: number +} + +export type RpcReadResult = + | { + readonly compatible: true + readonly variant: Variant + readonly value: Value + readonly salvage: RpcSalvageReport + } + | { readonly compatible: false; readonly issues: readonly RpcDecodeIssue[] } + +/** Reads the payload its acceptance policy admits into one declared semantic variant. */ +export type RpcCompatibleReader = ( + raw: Raw +) => RpcReadResult + +export type RpcStreamOpenerReply = RpcSuccess & { streaming: true } + +// Only a fulfilled outer envelope is classified. Transport rejection stays on the promise +// channel, so an operation in a Promise.all still fails the group immediately instead of +// waiting for a peer and letting a later policy surface a different error. +export type RpcRequestOutcome = + | { + readonly kind: 'outer-refused' + readonly error: RpcFailure['error'] + readonly raw: RpcResponse + } + | { + readonly kind: 'decoded' + readonly variant: Variant + readonly value: Value + readonly raw: RpcResponse + readonly salvage: RpcSalvageReport + } + | { + readonly kind: 'incompatible' + readonly raw: RpcResponse + readonly issues: readonly RpcDecodeIssue[] + } + +export type RpcOperation< + Method extends RpcMethodName, + Acceptance extends RpcAcceptanceName, + Variant extends string, + Value, + Barrier extends RpcInterpretationBarrier +> = { + /** Family name, not the method: two families may share a method with different acceptance. */ + readonly name: string + readonly method: Method + readonly barrier: Barrier +} & { + [Policy in RpcAcceptanceName]: { + readonly acceptance: Policy + readonly read: Policy extends 'require-result-or-throw' | 'object-result-or-null' + ? RpcCompatibleReader + : undefined + } +}[Acceptance] + +// Internal interpreter view; public send APIs retain the policy/reader correlation. +export type AnyRpcOperation = Pick< + RpcOperation, + 'name' | 'method' | 'acceptance' | 'barrier' +> & { readonly read: RpcCompatibleReader | undefined } + +/** The verdict the declared policy yields. Not a per-call choice. */ +export type RpcVerdict< + Acceptance extends RpcAcceptanceName, + Value +> = Acceptance extends 'require-result-or-throw' + ? Value + : Acceptance extends 'object-result-or-null' + ? Value | null + : Acceptance extends 'method-not-found-refusal' + ? boolean + : Acceptance extends 'streaming-opener' + ? RpcStreamOpenerReply | null + : never + +export type RpcOperationSettlement = + | { readonly status: 'fulfilled'; readonly outcome: RpcRequestOutcome } + | { readonly status: 'rejected'; readonly error: unknown } + +type RpcOperationDefinition< + Method extends RpcMethodName, + Barrier extends RpcInterpretationBarrier +> = { + name: string + method: Method + barrier: Barrier +} + +export type RequireResultRpcDefinition< + Method extends RpcMethodName, + Variant extends string, + Value, + Barrier extends RpcInterpretationBarrier +> = RpcOperationDefinition & { + acceptance: 'require-result-or-throw' + read: RpcCompatibleReader +} + +export type ObjectResultRpcDefinition< + Method extends RpcMethodName, + Variant extends string, + Value, + Barrier extends RpcInterpretationBarrier +> = RpcOperationDefinition & { + acceptance: 'object-result-or-null' + // Raw is the non-null object rpcObjectResultOrNull admits; anything else is incompatible. + read: RpcCompatibleReader, Variant, Value> +} + +export type CapabilityProbeRpcDefinition< + Method extends RpcMethodName, + Barrier extends RpcInterpretationBarrier +> = RpcOperationDefinition & { + acceptance: 'method-not-found-refusal' + /** A probe answers from the refusal code alone, so a reader would have nothing to read. */ + read?: never +} + +export type StreamOpenerRpcDefinition< + Method extends RpcMethodName, + Barrier extends RpcInterpretationBarrier +> = RpcOperationDefinition & { + acceptance: 'streaming-opener' + /** The opener's value is the reply itself; frames arrive on the subscription, not here. */ + read?: never +} diff --git a/mobile/src/transport/rpc-operation-result-reader.test.ts b/mobile/src/transport/rpc-operation-result-reader.test.ts new file mode 100644 index 00000000000..0a5393352b3 --- /dev/null +++ b/mobile/src/transport/rpc-operation-result-reader.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { salvagingArray } from '../../../src/shared/zod-salvage' +import { FakeSession } from './mobile-endpoint-supervisor-test-fakes' +import { captureRpcOperationSettlement, defineRpcOperation } from './rpc-operation' +import { rpcResultVariant, rpcResultVariants } from './rpc-operation-result-reader' +import { + WORKSPACE_ROWS_SCHEMA, + rpcSuccess, + workspaceRowsReader +} from './rpc-operation-test-families' + +const SALVAGING_ROWS_SCHEMA = z.object({ + worktrees: salvagingArray(z.object({ id: z.string() })) +}) + +const salvagingReader = rpcResultVariant('rows', SALVAGING_ROWS_SCHEMA) + +describe('a single-variant reader', () => { + it('decodes a matching payload and reports nothing dropped', () => { + expect(workspaceRowsReader({ worktrees: [{ id: 'w1' }] })).toEqual({ + compatible: true, + variant: 'rows', + value: { worktrees: [{ id: 'w1' }] }, + salvage: { droppedPaths: [], droppedCount: 0 } + }) + }) + + it('reports dotted issue paths for a payload it cannot read', () => { + const result = workspaceRowsReader({ worktrees: [{ id: 1 }] }) + + expect(result.compatible).toBe(false) + if (result.compatible) { + throw new Error('expected an incompatible read') + } + expect(result.issues).toEqual([{ path: 'worktrees.0.id', message: expect.any(String) }]) + }) + + it('carries the salvage report when the schema drops an element', () => { + const result = salvagingReader({ worktrees: [{ id: 'w1' }, { id: 7 }] }) + + expect(result).toEqual({ + compatible: true, + variant: 'rows', + value: { worktrees: [{ id: 'w1' }] }, + // zod-salvage reports the path relative to the salvaging container, not the envelope. + salvage: { droppedPaths: ['1'], droppedCount: 1 } + }) + }) + + // zod-salvage keeps its collector at module level, so a leak here would blame the next + // reply for the previous one's drops. + it('does not leak drop diagnostics into the next read', () => { + salvagingReader({ worktrees: [{ id: 7 }] }) + + expect(salvagingReader({ worktrees: [{ id: 'w1' }] })).toMatchObject({ + salvage: { droppedPaths: [], droppedCount: 0 } + }) + }) + + it('bounds the issues it reports and says how many it dropped', () => { + const wide = { worktrees: Array.from({ length: 25 }, () => ({ id: 1 })) } + const result = workspaceRowsReader(wide) + + if (result.compatible) { + throw new Error('expected an incompatible read') + } + expect(result.issues).toHaveLength(21) + expect(result.issues[20]).toEqual({ path: '', message: '5 further issues omitted' }) + }) + + // The caveat that comes with zod-salvage: it wraps a synchronous parse only. An async + // schema must read as incompatible rather than leaking a promise into the outcome. + it('reads an async schema as incompatible instead of leaking a promise', () => { + const asyncReader = rpcResultVariant( + 'rows', + z.object({ id: z.string() }).refine(async () => true) + ) + + const result = asyncReader({ id: 'w1' }) + + expect(result.compatible).toBe(false) + if (result.compatible) { + throw new Error('expected an incompatible read') + } + expect(result.issues[0].message).toContain('synchronous parse') + }) +}) + +describe('a multi-variant reader', () => { + const reader = rpcResultVariants<'rows' | 'legacy-array', unknown>([ + workspaceRowsReader, + rpcResultVariant('legacy-array', z.array(z.object({ id: z.string() }))) + ]) + + it('takes the first declared variant that reads', () => { + expect(reader({ worktrees: [{ id: 'w1' }] })).toMatchObject({ variant: 'rows' }) + }) + + it('falls through to a later variant', () => { + expect(reader([{ id: 'w1' }])).toMatchObject({ + variant: 'legacy-array', + value: [{ id: 'w1' }] + }) + }) + + it('tags every variant it tried when none of them reads', () => { + const result = reader('neither shape') + + if (result.compatible) { + throw new Error('expected an incompatible read') + } + expect(result.issues.map((issue) => issue.path)).toEqual(['rows', 'legacy-array']) + }) +}) + +describe('salvage through a descriptor', () => { + const salvagingList = defineRpcOperation({ + name: 'test.salvagingWorkspaceList', + method: 'worktree.ps', + acceptance: 'require-result-or-throw', + barrier: 'on-settle', + read: salvagingReader + }) + + it('reports the dropped paths on the decoded outcome', async () => { + const session = new FakeSession('connected') + session.sendRequest.mockResolvedValue(rpcSuccess({ worktrees: [{ id: 'w1' }, { id: 7 }] })) + + const settlement = await captureRpcOperationSettlement(session, salvagingList, {}) + + expect(settlement.status === 'fulfilled' && settlement.outcome).toMatchObject({ + kind: 'decoded', + value: { worktrees: [{ id: 'w1' }] }, + salvage: { droppedPaths: ['1'], droppedCount: 1 } + }) + }) +}) + +describe('the shared workspace schema', () => { + it('is the strict shape the salvaging variant relaxes', () => { + expect(WORKSPACE_ROWS_SCHEMA.safeParse({ worktrees: [{ id: 7 }] }).success).toBe(false) + expect(SALVAGING_ROWS_SCHEMA.safeParse({ worktrees: [{ id: 7 }] }).success).toBe(true) + }) +}) diff --git a/mobile/src/transport/rpc-operation-result-reader.ts b/mobile/src/transport/rpc-operation-result-reader.ts new file mode 100644 index 00000000000..b9341e0e8bc --- /dev/null +++ b/mobile/src/transport/rpc-operation-result-reader.ts @@ -0,0 +1,89 @@ +import { z } from 'zod' +import { collectSalvageDrops } from '../../../src/shared/zod-salvage' +import type { RpcCompatibleReader, RpcDecodeIssue } from './rpc-operation-contract' + +const MAX_REPORTED_DECODE_ISSUES = 20 + +/** A reader that names the semantic variant it decodes, so a combinator can tag its issues. */ +export type NamedRpcResultReader = RpcCompatibleReader< + unknown, + Variant, + Value +> & { readonly variant: Variant } + +/** Builds a compatible reader for one semantic variant of a reply payload. */ +export function rpcResultVariant( + variant: Variant, + schema: Schema +): NamedRpcResultReader> { + const read: RpcCompatibleReader> = (raw) => { + try { + // Why: zod-salvage holds module-level collector state and wraps a *synchronous* + // parse only; safeParse throws on an async schema, which reads as incompatible. + const parsed = collectSalvageDrops(() => schema.safeParse(raw)) + if (!parsed.value.success) { + return { compatible: false, issues: decodeIssues(parsed.value.error) } + } + return { + compatible: true, + variant, + value: parsed.value.data as z.output, + salvage: { droppedPaths: parsed.droppedPaths, droppedCount: parsed.droppedCount } + } + } catch (error) { + return { compatible: false, issues: [{ path: '', message: describeThrow(error) }] } + } + } + return Object.assign(read, { variant }) +} + +/** Tries each variant in declared order and takes the first that reads. */ +export function rpcResultVariants( + readers: readonly [ + NamedRpcResultReader, + ...NamedRpcResultReader[] + ] +): RpcCompatibleReader { + return (raw) => { + const issues: RpcDecodeIssue[] = [] + for (const reader of readers) { + const result = reader(raw) + if (result.compatible) { + return result + } + for (const issue of result.issues) { + issues.push({ path: joinPath(reader.variant, issue.path), message: issue.message }) + } + } + return { compatible: false, issues: boundIssues(issues) } + } +} + +function decodeIssues(error: z.ZodError): RpcDecodeIssue[] { + return boundIssues( + error.issues.map((issue) => ({ + path: issue.path.map((segment) => String(segment)).join('.'), + message: issue.message + })) + ) +} + +// Why: a hostile or very foreign reply can issue per element; report a bounded sample and +// say how many were dropped rather than letting the diagnostic grow with the payload. +function boundIssues(issues: readonly RpcDecodeIssue[]): RpcDecodeIssue[] { + if (issues.length <= MAX_REPORTED_DECODE_ISSUES) { + return [...issues] + } + return [ + ...issues.slice(0, MAX_REPORTED_DECODE_ISSUES), + { path: '', message: `${issues.length - MAX_REPORTED_DECODE_ISSUES} further issues omitted` } + ] +} + +function joinPath(variant: string, path: string): string { + return path ? `${variant}.${path}` : variant +} + +function describeThrow(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/mobile/src/transport/rpc-operation-test-families.ts b/mobile/src/transport/rpc-operation-test-families.ts new file mode 100644 index 00000000000..73554b7a4f8 --- /dev/null +++ b/mobile/src/transport/rpc-operation-test-families.ts @@ -0,0 +1,99 @@ +import { z } from 'zod' +import type { RpcResponse } from './types' +import type { RpcCompatibleReader } from './rpc-operation-contract' +import { rpcResultVariant, rpcResultVariants } from './rpc-operation-result-reader' +import { defineRpcOperation } from './rpc-operation' + +// Operation families used by the rpc-operation suites and by the compile fence. Kept in one +// place so the tests and the fence assert against the same descriptors, and so tsc sees them +// (the app tsconfig excludes *.test.ts). No app code imports this module. + +export const WORKSPACE_ROWS_SCHEMA = z.object({ + worktrees: z.array(z.object({ id: z.string() })) +}) + +const LEGACY_WORKSPACE_ROWS_SCHEMA = z.array(z.object({ id: z.string() })) + +export type WorkspaceRows = z.output +export type LegacyWorkspaceRows = z.output + +export const workspaceRowsReader = rpcResultVariant('rows', WORKSPACE_ROWS_SCHEMA) + +/** Two semantic variants: the modern envelope, then a host that answered a bare array. */ +export const workspaceRowsOrLegacyReader: RpcCompatibleReader< + unknown, + 'rows' | 'legacy-array', + WorkspaceRows | LegacyWorkspaceRows +> = rpcResultVariants<'rows' | 'legacy-array', WorkspaceRows | LegacyWorkspaceRows>([ + workspaceRowsReader, + rpcResultVariant('legacy-array', LEGACY_WORKSPACE_ROWS_SCHEMA) +]) + +export const workspaceListOrThrow = defineRpcOperation({ + name: 'test.workspaceListOrThrow', + method: 'worktree.ps', + acceptance: 'require-result-or-throw', + barrier: 'on-settle', + read: workspaceRowsOrLegacyReader +}) + +// Same method, a different family: main's callers disagreed about acceptance, so both rules +// stay named rather than being unified behind one descriptor. +export const workspaceListOrNull = defineRpcOperation({ + name: 'test.workspaceListOrNull', + method: 'worktree.ps', + acceptance: 'object-result-or-null', + barrier: 'on-settle', + read: workspaceRowsReader +}) + +export const worktreePsProbe = defineRpcOperation({ + name: 'test.worktreePsProbe', + method: 'worktree.ps', + acceptance: 'method-not-found-refusal', + barrier: 'on-settle' +}) + +export const terminalStreamOpener = defineRpcOperation({ + name: 'test.terminalStreamOpener', + method: 'terminal.subscribe', + acceptance: 'streaming-opener', + barrier: 'on-settle' +}) + +export const workspaceListAtBarrier = defineRpcOperation({ + name: 'test.workspaceListAtBarrier', + method: 'worktree.ps', + acceptance: 'require-result-or-throw', + barrier: 'after-all-requests', + read: workspaceRowsReader +}) + +export const terminalListAtBarrier = defineRpcOperation({ + name: 'test.terminalListAtBarrier', + method: 'terminal.list', + acceptance: 'object-result-or-null', + barrier: 'after-all-requests', + read: rpcResultVariant('terminals', z.object({ terminals: z.array(z.unknown()) })) +}) + +export const worktreePsProbeAtBarrier = defineRpcOperation({ + name: 'test.worktreePsProbeAtBarrier', + method: 'worktree.ps', + acceptance: 'method-not-found-refusal', + barrier: 'after-all-requests' +}) + +export function rpcSuccess(result: unknown, streaming?: true): RpcResponse { + return { + id: 'rpc-1', + ok: true, + result, + _meta: { runtimeId: 'runtime-1' }, + ...(streaming ? { streaming } : {}) + } +} + +export function rpcRefusal(code: string, message = 'Nope'): RpcResponse { + return { id: 'rpc-1', ok: false, error: { code, message }, _meta: { runtimeId: 'runtime-1' } } +} diff --git a/mobile/src/transport/rpc-operation.test.ts b/mobile/src/transport/rpc-operation.test.ts new file mode 100644 index 00000000000..91542b48528 --- /dev/null +++ b/mobile/src/transport/rpc-operation.test.ts @@ -0,0 +1,335 @@ +import { describe, expect, it } from 'vitest' +import type { RpcResponse } from './types' +import { FakeSession } from './mobile-endpoint-supervisor-test-fakes' +import { + createStableLogicalRpcClient, + isLogicalClientCutoverError +} from './stable-logical-rpc-client' +import { isRpcDeliveryUnknown, markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' +import { + RpcIncompatibleReplyError, + isRpcIncompatibleReplyError +} from './rpc-incompatible-reply-error' +import { captureRpcOperationSettlement, runRpcOperation } from './rpc-operation' +import { + rpcRefusal, + rpcSuccess, + terminalListAtBarrier, + terminalStreamOpener, + workspaceListOrNull, + workspaceListOrThrow, + worktreePsProbe +} from './rpc-operation-test-families' + +function connectedSession(response?: RpcResponse): FakeSession { + const session = new FakeSession('connected') + if (response) { + session.sendRequest.mockResolvedValue(response) + } + return session +} + +const rows = { worktrees: [{ id: 'w1' }] } + +describe('request classification', () => { + it('decodes a compatible reply, naming the variant and keeping the raw envelope', async () => { + const response = rpcSuccess(rows) + const settlement = await captureRpcOperationSettlement( + connectedSession(response), + workspaceListOrThrow, + {} + ) + + expect(settlement).toEqual({ + status: 'fulfilled', + outcome: { + kind: 'decoded', + variant: 'rows', + value: rows, + raw: response, + salvage: { droppedPaths: [], droppedCount: 0 } + } + }) + }) + + it('names the legacy variant when the host answered the older shape', async () => { + const settlement = await captureRpcOperationSettlement( + connectedSession(rpcSuccess([{ id: 'w1' }])), + workspaceListOrThrow, + {} + ) + + expect(settlement.status === 'fulfilled' && settlement.outcome.kind).toBe('decoded') + expect(settlement.status === 'fulfilled' && settlement.outcome).toMatchObject({ + variant: 'legacy-array', + value: [{ id: 'w1' }] + }) + }) + + it('classifies a refusal as outer-refused rather than throwing', async () => { + const response = rpcRefusal('runtime_error', 'boom') + const settlement = await captureRpcOperationSettlement( + connectedSession(response), + workspaceListOrThrow, + {} + ) + + expect(settlement).toEqual({ + status: 'fulfilled', + outcome: { + kind: 'outer-refused', + error: { code: 'runtime_error', message: 'boom' }, + raw: response + } + }) + }) + + it('classifies a reply the reader cannot read as incompatible, with bounded issues', async () => { + const response = rpcSuccess({ worktrees: [{ id: 7 }] }) + const settlement = await captureRpcOperationSettlement( + connectedSession(response), + workspaceListOrThrow, + {} + ) + + expect(settlement.status).toBe('fulfilled') + if (settlement.status !== 'fulfilled' || settlement.outcome.kind !== 'incompatible') { + throw new Error('expected an incompatible outcome') + } + expect(settlement.outcome.raw).toBe(response) + expect(settlement.outcome.issues.map((issue) => issue.path)).toContain('rows.worktrees.0.id') + }) + + it('treats a reply that is not an object as incompatible for the nullable family', async () => { + const settlement = await captureRpcOperationSettlement( + connectedSession(rpcSuccess('not an object')), + workspaceListOrNull, + {} + ) + + expect(settlement.status === 'fulfilled' && settlement.outcome.kind).toBe('incompatible') + }) + + it('treats a throwing reader as incompatible, never as a transport failure', async () => { + const exploding = { + ...workspaceListOrThrow, + read: () => { + throw new Error('reader exploded') + } + } + const settlement = await captureRpcOperationSettlement( + connectedSession(rpcSuccess(rows)), + exploding, + {} + ) + + expect(settlement.status === 'fulfilled' && settlement.outcome.kind).toBe('incompatible') + }) +}) + +describe('transport rejection stays on the promise channel', () => { + it('rejects with the original error object', async () => { + const error = new Error('socket closed') + const session = new FakeSession('connected') + session.sendRequest.mockRejectedValue(error) + + await expect(runRpcOperation(session, workspaceListOrThrow, {})).rejects.toBe(error) + }) + + it('keeps a delivery-unknown mark readable through the descriptor', async () => { + const error = markRpcDeliveryUnknown(new Error('socket closed before response')) + const session = new FakeSession('connected') + session.sendRequest.mockRejectedValue(error) + + const caught = await runRpcOperation(session, workspaceListOrThrow, {}).catch( + (thrown: unknown) => thrown + ) + + expect(caught).toBe(error) + expect(isRpcDeliveryUnknown(caught)).toBe(true) + }) + + // The cutover predicate matches class or exact message because instanceof misses across + // bundle copies; a clone from another copy must still read as a cutover through the descriptor. + it('keeps a cutover error from another bundle copy recognisable', async () => { + class ForeignBundleCutoverError extends Error {} + const error = new ForeignBundleCutoverError('RPC interrupted by connection migration') + const session = new FakeSession('connected') + session.sendRequest.mockRejectedValue(error) + + const caught = await runRpcOperation(session, workspaceListOrThrow, {}).catch( + (thrown: unknown) => thrown + ) + + expect(caught).toBe(error) + expect(isLogicalClientCutoverError(caught)).toBe(true) + }) + + it('fails a Promise.all group immediately instead of waiting for a stalled peer', async () => { + const error = new Error('socket closed') + const failing = new FakeSession('connected') + failing.sendRequest.mockRejectedValue(error) + const stalled = new FakeSession('connected') + stalled.sendRequest.mockReturnValue(new Promise(() => {})) + + const group = Promise.all([ + runRpcOperation(failing, workspaceListOrThrow, {}), + runRpcOperation(stalled, workspaceListOrThrow, {}) + ]) + const raced = await Promise.race([ + group.then( + () => 'resolved' as const, + (caught: unknown) => caught + ), + new Promise((resolve) => setTimeout(() => resolve('still waiting'), 50)) + ]) + + expect(raced).toBe(error) + }) + + it('only captures a rejection when a caller names the all-settled helper', async () => { + const error = new Error('socket closed') + const session = new FakeSession('connected') + session.sendRequest.mockRejectedValue(error) + + await expect(captureRpcOperationSettlement(session, workspaceListOrThrow, {})).resolves.toEqual( + { status: 'rejected', error } + ) + }) +}) + +describe('the send path', () => { + it('carries the worktree.ps capability stamp, because it goes through the logical client', async () => { + const session = connectedSession(rpcSuccess(rows)) + const logical = createStableLogicalRpcClient(session, 'lan') + + await runRpcOperation(logical, workspaceListOrThrow, { limit: 500 }) + + expect(session.sendRequest).toHaveBeenCalledWith( + 'worktree.ps', + { limit: 500, supportsWorktreeVisibilitySourceDefaults: true }, + undefined + ) + }) + + it('sends the caller params object untouched for a method with no projection', async () => { + const session = connectedSession(rpcSuccess({ terminals: [] })) + const logical = createStableLogicalRpcClient(session, 'lan') + const params = { worktree: 'w1' } + + await captureRpcOperationSettlement(logical, terminalListAtBarrier, params, { + timeoutMs: 1234 + }) + + expect(session.sendRequest.mock.calls[0][0]).toBe('terminal.list') + expect(session.sendRequest.mock.calls[0][1]).toBe(params) + expect(session.sendRequest.mock.calls[0][2]).toEqual({ timeoutMs: 1234 }) + }) +}) + +describe('acceptance is a property of the family', () => { + const refusal = rpcRefusal('method_not_found', 'no such method') + + it('surfaces the refusal as a coded error for the throwing family', async () => { + await expect( + runRpcOperation(connectedSession(refusal), workspaceListOrThrow, {}) + ).rejects.toThrow('method_not_found: no such method') + }) + + it('answers null to the same refusal for the nullable family', async () => { + await expect( + runRpcOperation(connectedSession(refusal), workspaceListOrNull, {}) + ).resolves.toBeNull() + }) + + it('answers true to the same refusal for the capability probe', async () => { + await expect(runRpcOperation(connectedSession(refusal), worktreePsProbe, {})).resolves.toBe( + true + ) + }) + + it('keeps the probe false for another refusal code and for a success', async () => { + await expect( + runRpcOperation(connectedSession(rpcRefusal('runtime_error')), worktreePsProbe, {}) + ).resolves.toBe(false) + await expect( + runRpcOperation(connectedSession(rpcSuccess(rows)), worktreePsProbe, {}) + ).resolves.toBe(false) + }) + + it('returns the decoded value for the throwing family', async () => { + await expect( + runRpcOperation(connectedSession(rpcSuccess(rows)), workspaceListOrThrow, {}) + ).resolves.toEqual(rows) + }) + + it('returns the reply itself only when it opened a stream', async () => { + const opener = rpcSuccess({ subscriptionId: 's1' }, true) + await expect( + runRpcOperation(connectedSession(opener), terminalStreamOpener, { terminal: 't1' }) + ).resolves.toBe(opener) + await expect( + runRpcOperation( + connectedSession(rpcSuccess({ subscriptionId: 's1' })), + terminalStreamOpener, + { + terminal: 't1' + } + ) + ).resolves.toBeNull() + await expect( + runRpcOperation(connectedSession(rpcRefusal('runtime_error')), terminalStreamOpener, { + terminal: 't1' + }) + ).resolves.toBeNull() + }) +}) + +describe('an incompatible reply', () => { + const incompatible = rpcSuccess({ worktrees: [{ id: 7 }] }) + + it('throws a named incompatible-reply error for the throwing family', async () => { + const caught = await runRpcOperation( + connectedSession(incompatible), + workspaceListOrThrow, + {} + ).catch((thrown: unknown) => thrown) + + expect(caught).toBeInstanceOf(RpcIncompatibleReplyError) + expect(isRpcIncompatibleReplyError(caught)).toBe(true) + expect((caught as RpcIncompatibleReplyError).method).toBe('worktree.ps') + expect((caught as RpcIncompatibleReplyError).operationName).toBe('test.workspaceListOrThrow') + expect((caught as RpcIncompatibleReplyError).issues.length).toBeGreaterThan(0) + }) + + // A reply nobody can read says nothing about what the host did, so it must not look like + // either of the two errors the mutation retry loops replay on. + it('authorizes no retry', async () => { + const caught = await runRpcOperation( + connectedSession(incompatible), + workspaceListOrThrow, + {} + ).catch((thrown: unknown) => thrown) + + expect(isRpcDeliveryUnknown(caught)).toBe(false) + expect(isLogicalClientCutoverError(caught)).toBe(false) + }) + + it('answers null for the nullable family', async () => { + await expect( + runRpcOperation(connectedSession(incompatible), workspaceListOrNull, {}) + ).resolves.toBeNull() + }) +}) + +describe('a descriptor', () => { + it('cannot have its policy or barrier swapped at runtime', () => { + expect(Object.isFrozen(workspaceListOrThrow)).toBe(true) + expect(() => { + ;(workspaceListOrThrow as { acceptance: string }).acceptance = 'object-result-or-null' + }).toThrow(TypeError) + expect(() => { + ;(workspaceListOrThrow as { barrier: string }).barrier = 'after-all-requests' + }).toThrow(TypeError) + }) +}) diff --git a/mobile/src/transport/rpc-operation.ts b/mobile/src/transport/rpc-operation.ts new file mode 100644 index 00000000000..def072dac81 --- /dev/null +++ b/mobile/src/transport/rpc-operation.ts @@ -0,0 +1,282 @@ +import type { UnvalidatedRpcRequestPort, SendRequestOptions } from './unvalidated-rpc-request-port' +import type { RpcMethodName, RpcSendParams } from './rpc-params-contract' +import type { RpcResponse } from './types' +import { + isMethodNotFoundRefusal, + isStreamingOpenerReply, + requireRpcResultOrThrowCodedError, + rpcObjectResultOrNull +} from './rpc-acceptance-policies' +import { RpcIncompatibleReplyError } from './rpc-incompatible-reply-error' +import type { + AnyRpcOperation, + CapabilityProbeRpcDefinition, + ObjectResultRpcDefinition, + RpcAcceptanceName, + RpcCompatibleReader, + RpcDecodeIssue, + RpcInterpretationBarrier, + RpcOperation, + RpcOperationSettlement, + RpcRequestOutcome, + RpcSalvageReport, + RequireResultRpcDefinition, + StreamOpenerRpcDefinition, + RpcVerdict +} from './rpc-operation-contract' + +const NOTHING_SALVAGED: RpcSalvageReport = { droppedPaths: [], droppedCount: 0 } + +type RpcOperationDefinitionInput = + | RequireResultRpcDefinition + | ObjectResultRpcDefinition + | CapabilityProbeRpcDefinition + | StreamOpenerRpcDefinition + +export function defineRpcOperation< + Method extends RpcMethodName, + Variant extends string, + Value, + Barrier extends RpcInterpretationBarrier +>( + definition: RequireResultRpcDefinition +): RpcOperation +export function defineRpcOperation< + Method extends RpcMethodName, + Variant extends string, + Value, + Barrier extends RpcInterpretationBarrier +>( + definition: ObjectResultRpcDefinition +): RpcOperation +export function defineRpcOperation< + Method extends RpcMethodName, + Barrier extends RpcInterpretationBarrier +>( + definition: CapabilityProbeRpcDefinition +): RpcOperation +export function defineRpcOperation< + Method extends RpcMethodName, + Barrier extends RpcInterpretationBarrier +>( + definition: StreamOpenerRpcDefinition +): RpcOperation +export function defineRpcOperation(definition: RpcOperationDefinitionInput): AnyRpcOperation { + // Why: frozen so no call site can swap the policy or the barrier on a shared descriptor. + return Object.freeze({ + name: definition.name, + method: definition.method, + acceptance: definition.acceptance, + barrier: definition.barrier, + // Why: classifyReply only ever hands a reader the payload its own policy admitted, so + // the object policy's narrower parameter is sound to store as unknown. + read: definition.read as RpcCompatibleReader | undefined + }) +} + +/** Sends the operation without interpreting it; transport rejection stays on the promise. */ +async function request( + client: UnvalidatedRpcRequestPort, + operation: AnyRpcOperation, + params: unknown, + options?: SendRequestOptions +): Promise> { + // Why: no try/catch here. A transport failure must reach the caller as the original error + // object — isLogicalClientCutoverError and isRpcDeliveryUnknown both die on a wrapper — + // and an always-settled send would make Promise.all wait for a peer where today the group + // fails immediately, letting a later policy surface a different error. + const response = await client.sendRequest(operation.method, params, options) + return classifyReply(operation, response) +} + +type AdmittedPayload = + | { readonly admitted: true; readonly value: unknown } + | { readonly admitted: false; readonly issues: readonly RpcDecodeIssue[] } + +// The payload the operation's own acceptance policy admits from a fulfilled success. +function admitPayload(operation: AnyRpcOperation, response: RpcResponse): AdmittedPayload { + switch (operation.acceptance) { + case 'object-result-or-null': { + const object = rpcObjectResultOrNull(response) + return object === null + ? { admitted: false, issues: [{ path: 'result', message: 'not a non-null object' }] } + : { admitted: true, value: object } + } + case 'streaming-opener': + return isStreamingOpenerReply(response) + ? { admitted: true, value: response } + : { admitted: false, issues: [{ path: 'streaming', message: 'reply opened no stream' }] } + default: + // Reuses the policy rather than reading `.result` again; a success never throws here. + return { admitted: true, value: requireRpcResultOrThrowCodedError(response) } + } +} + +const READERLESS_VARIANTS: Record = { + 'method-not-found-refusal': 'accepted', + 'streaming-opener': 'stream-opened' +} + +function classifyReply( + operation: AnyRpcOperation, + response: RpcResponse +): RpcRequestOutcome { + if (!response.ok) { + return { kind: 'outer-refused', error: response.error, raw: response } + } + const payload = admitPayload(operation, response) + if (!payload.admitted) { + return { kind: 'incompatible', raw: response, issues: payload.issues } + } + const read = operation.read + if (!read) { + return { + kind: 'decoded', + variant: READERLESS_VARIANTS[operation.acceptance] ?? 'accepted', + value: payload.value, + raw: response, + salvage: NOTHING_SALVAGED + } + } + let result: ReturnType + try { + result = read(payload.value) + } catch (error) { + // A reader that throws is an incompatible reply, never a transport failure. + return { + kind: 'incompatible', + raw: response, + issues: [{ path: '', message: error instanceof Error ? error.message : String(error) }] + } + } + if (!result.compatible) { + return { kind: 'incompatible', raw: response, issues: result.issues } + } + return { + kind: 'decoded', + variant: result.variant, + value: result.value, + raw: response, + salvage: result.salvage + } +} + +// Applies the operation's declared acceptance policy. Private on purpose: there is no +// free-standing callOrThrow, so no call site can pick a different rule for the same reply. +function interpret( + operation: AnyRpcOperation, + settled: RpcRequestOutcome +): unknown { + const acceptance: RpcAcceptanceName = operation.acceptance + switch (acceptance) { + case 'require-result-or-throw': + if (settled.kind === 'outer-refused') { + // Reuses the policy so the thrown `code: message` text cannot drift from main's. + return requireRpcResultOrThrowCodedError(settled.raw) + } + if (settled.kind === 'incompatible') { + throw new RpcIncompatibleReplyError(operation.name, operation.method, settled.issues) + } + return settled.value + case 'object-result-or-null': + return settled.kind === 'decoded' ? settled.value : null + case 'method-not-found-refusal': + return settled.kind === 'outer-refused' ? isMethodNotFoundRefusal(settled.raw) : false + case 'streaming-opener': + return settled.kind === 'decoded' && isStreamingOpenerReply(settled.raw) ? settled.raw : null + } +} + +function interpretSettlement( + operation: AnyRpcOperation, + settlement: RpcOperationSettlement +): unknown { + if (settlement.status === 'rejected') { + // Why: rethrow the original object — isRpcDeliveryUnknown is a WeakSet on identity and + // isLogicalClientCutoverError matches class or exact message; a wrapper loses both. + throw settlement.error + } + return interpret(operation, settlement.outcome) +} + +/** Sends and interprets at the operation's own barrier. Only for barrier 'on-settle'. */ +export async function runRpcOperation< + Method extends RpcMethodName, + Acceptance extends RpcAcceptanceName, + Variant extends string, + Value +>( + client: UnvalidatedRpcRequestPort, + operation: RpcOperation, + params: RpcSendParams, + options?: SendRequestOptions +): Promise> { + const outcome = await request(client, operation, params, options) + return interpret(operation, outcome) as RpcVerdict +} + +/** The named opt-in to all-settled semantics. Yields an outcome, never a verdict: the + * verdict still comes only from the declared policy, at the declared barrier. */ +export async function captureRpcOperationSettlement< + Method extends RpcMethodName, + Acceptance extends RpcAcceptanceName, + Variant extends string, + Value, + Barrier extends RpcInterpretationBarrier +>( + client: UnvalidatedRpcRequestPort, + operation: RpcOperation, + params: RpcSendParams, + options?: SendRequestOptions +): Promise> { + try { + const outcome = await request(client, operation, params, options) + return { status: 'fulfilled', outcome: outcome as RpcRequestOutcome } + } catch (error) { + return { status: 'rejected', error } + } +} + +export type PendingRpcOperation = { + readonly operation: Op + readonly settlement: Promise> +} + +/** Starts a request whose interpretation is deferred to the barrier it declared. */ +export function startRpcOperation< + Method extends RpcMethodName, + Acceptance extends RpcAcceptanceName, + Variant extends string, + Value +>( + client: UnvalidatedRpcRequestPort, + operation: RpcOperation, + params: RpcSendParams, + options?: SendRequestOptions +): PendingRpcOperation> { + return { + operation, + settlement: captureRpcOperationSettlement(client, operation, params, options) + } +} + +type RpcBarrierVerdicts[]> = { + [Index in keyof Pending]: Pending[Index] extends PendingRpcOperation< + RpcOperation + > + ? RpcVerdict + : never +} + +/** Awaits every raw request, then interprets in declared order. */ +export async function interpretAtRpcBarrier< + Pending extends readonly PendingRpcOperation[] +>(pending: Pending): Promise> { + // Why: interpreting as each request lands would let whichever peer failed first decide the + // error the user sees and how long the screen spins. Declared order makes that a property + // of the definition instead of a race. + const settlements = await Promise.all(pending.map((entry) => entry.settlement)) + return pending.map((entry, index) => + interpretSettlement(entry.operation, settlements[index]) + ) as RpcBarrierVerdicts +} diff --git a/mobile/src/transport/rpc-params-contract.ts b/mobile/src/transport/rpc-params-contract.ts index fcf3303e965..883a921c079 100644 --- a/mobile/src/transport/rpc-params-contract.ts +++ b/mobile/src/transport/rpc-params-contract.ts @@ -2,7 +2,11 @@ // The schemas behind these types must never reach the bundle: requiredString is // z.unknown().transform(...), so a client-side parse coerces a non-string to '' // instead of rejecting it, silently changing the bytes on the wire. +// +// RpcSendParams is the outgoing type; RpcParams is the shape the handler sees after +// parsing, which is not what a sender may write (see rpc-send-params.ts). export type { RpcMethodName, RpcParams } from '../../../src/shared/rpc-contract/rpc-params-catalog.generated' +export type { RpcSendParams } from '../../../src/shared/rpc-contract/rpc-send-params' diff --git a/mobile/src/transport/runtime-capability-probe.ts b/mobile/src/transport/runtime-capability-probe.ts index ef636552863..6bec0ca05bd 100644 --- a/mobile/src/transport/runtime-capability-probe.ts +++ b/mobile/src/transport/runtime-capability-probe.ts @@ -10,7 +10,7 @@ const FAILURE_RETRY_BASE_DELAY_MS = 1_000 const FAILURE_RETRY_MAX_DELAY_MS = 15_000 export function startRuntimeCapabilityProbe( - client: RpcClient, + client: Pick, onCapabilities: (capabilities: readonly string[]) => void ): () => void { let cancelled = false diff --git a/mobile/src/transport/settings-host-client-lifecycle.test.ts b/mobile/src/transport/settings-host-client-lifecycle.test.ts index be7621017ba..bd0c2048637 100644 --- a/mobile/src/transport/settings-host-client-lifecycle.test.ts +++ b/mobile/src/transport/settings-host-client-lifecycle.test.ts @@ -1,3 +1,6 @@ +vi.mock('../notifications/push-registration', () => ({ + attachPushRegistration: () => () => {} +})) import { createElement, Fragment, useEffect } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/mobile/src/transport/unvalidated-rpc-request-port-boundary.test.ts b/mobile/src/transport/unvalidated-rpc-request-port-boundary.test.ts new file mode 100644 index 00000000000..422b2925689 --- /dev/null +++ b/mobile/src/transport/unvalidated-rpc-request-port-boundary.test.ts @@ -0,0 +1,267 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { extname, join, relative, resolve } from 'node:path' +import ts from 'typescript' +import { describe, expect, it } from 'vitest' +import { + UNVALIDATED_RPC_REQUEST_PORT_OWNERS, + UNVALIDATED_RPC_REQUEST_PORT_PENDING, + type UnvalidatedRpcRequestPortEntry +} from './unvalidated-rpc-request-port-inventory' + +/** + * Ratchet for the raw RPC request port. + * + * `sendRequest` takes an unchecked method string and returns an envelope whose `result` is + * `unknown`. Every screen that reaches it re-decides acceptance and decoding for itself, which + * is the drift the RpcOperation contract exists to end. The port cannot be made unreachable by + * the type system today: `RpcClient` structurally carries it, and ~190 files hold a client. So + * the boundary is held as an inventory instead, and this test is what makes the inventory bind. + * + * Three failures, all of which mean "edit the list": + * - a file reaches the port and is on neither list, + * - a listed file no longer reaches it (stale entry — how allow-lists rot), + * - a listed file's reference count went up. + * + * What this does NOT catch, all accepted: + * - Reach laundered through a function type. A listed file can hand `client.sendRequest` to an + * unlisted one as a bare `(method: string) => Promise` and the receiver never + * names the port. Only two senders are named here; a third wrapper needs adding by hand. + * - Computed access — `client['send' + 'Request']` is not a literal in the AST. + * - Which method a listed file sends, or what it does with the reply. The count is a ceiling + * on how many times it reaches, nothing more. + * - Test files. `*.test.ts(x)` is not scanned: faking the port is how these suites work, and a + * test does not ship. A non-test file that fakes it (tsconfig excludes tests, so some do) is + * scanned and listed. + * A compile-time fence would catch the first two. That needs `RpcClient` to stop carrying the + * port, which needs the call sites migrated first — the thing this list is counting down. + */ + +const mobileRoot = fileURLToPath(new URL('../..', import.meta.url)) +const scannedRoots = ['app', 'src'].map((directory) => join(mobileRoot, directory)) +const sourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx']) +const portModule = join(mobileRoot, 'src', 'transport', 'unvalidated-rpc-request-port') + +/** The port and its own inventory are not offenders; the ratchet does not police itself. */ +const SELF_FILES = new Set([ + 'src/transport/unvalidated-rpc-request-port.ts', + 'src/transport/unvalidated-rpc-request-port-inventory.ts' +]) + +/** The coalescing second sender: same unchecked string in, same unread envelope out. */ +const SECOND_SENDER = 'sendSingleFlightRequest' + +function sourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name) + if (entry.isDirectory()) { + return entry.name === 'node_modules' ? [] : sourceFiles(path) + } + return [path] + }) +} + +function parse(path: string, source: string): ts.SourceFile { + const extension = extname(path) + return ts.createSourceFile( + path, + source, + ts.ScriptTarget.Latest, + true, + extension === '.tsx' || extension === '.jsx' ? ts.ScriptKind.TSX : ts.ScriptKind.TS + ) +} + +function targetsPortModule(path: string, node: ts.Node | undefined): boolean { + if (!node || !ts.isStringLiteral(node) || !node.text.startsWith('.')) { + return false + } + return resolve(path, '..', node.text) === portModule +} + +/** `client['sendRequest']` is one reach, not two: the element access already counted it. */ +function isCountedElementAccessArgument(node: ts.Node): boolean { + const parent: ts.Node | undefined = node.parent + return ( + parent !== undefined && + ts.isElementAccessExpression(parent) && + parent.argumentExpression === node + ) +} + +function declaresPortMember(node: ts.Node): boolean { + if ( + !ts.isPropertySignature(node) && + !ts.isMethodSignature(node) && + !ts.isMethodDeclaration(node) && + !ts.isPropertyDeclaration(node) && + !ts.isPropertyAssignment(node) && + !ts.isShorthandPropertyAssignment(node) + ) { + return false + } + const name = node.name + return (ts.isIdentifier(name) || ts.isStringLiteral(name)) && name.text === 'sendRequest' +} + +/** How many times this file reaches the raw port directly. Comments never count: this is AST. */ +export function rawRequestPortReferences(path: string, source: string): number { + let references = 0 + const visit = (node: ts.Node): void => { + if ( + (ts.isPropertyAccessExpression(node) && node.name.text === 'sendRequest') || + (ts.isElementAccessExpression(node) && + ts.isStringLiteral(node.argumentExpression) && + node.argumentExpression.text === 'sendRequest') || + declaresPortMember(node) || + (ts.isStringLiteral(node) && + node.text === 'sendRequest' && + !isCountedElementAccessArgument(node)) || + (ts.isIdentifier(node) && node.text === SECOND_SENDER) + ) { + references += 1 + } + if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { + references += targetsPortModule(path, node.moduleSpecifier) ? 1 : 0 + } + if ( + ts.isCallExpression(node) && + (node.expression.kind === ts.SyntaxKind.ImportKeyword || + (ts.isIdentifier(node.expression) && node.expression.text === 'require')) && + targetsPortModule(path, node.arguments[0]) + ) { + references += 1 + } + ts.forEachChild(node, visit) + } + visit(parse(path, source)) + return references +} + +const inventory: readonly UnvalidatedRpcRequestPortEntry[] = [ + ...UNVALIDATED_RPC_REQUEST_PORT_OWNERS, + ...UNVALIDATED_RPC_REQUEST_PORT_PENDING +] + +const scanned = scannedRoots + .flatMap(sourceFiles) + .filter((path) => sourceExtensions.has(extname(path))) + .filter((path) => !/\.test\.tsx?$/.test(path)) + .map((path) => relative(mobileRoot, path).split(/[/\\]/).join('/')) + .filter((file) => !SELF_FILES.has(file)) + +const observed = new Map( + scanned + .map( + (file) => + [ + file, + rawRequestPortReferences( + join(mobileRoot, file), + readFileSync(join(mobileRoot, file), 'utf8') + ) + ] as const + ) + .filter(([, references]) => references > 0) +) + +describe('unvalidated RPC request port boundary', () => { + const probe = join(mobileRoot, 'src', 'transport', 'probe.ts') + + it('counts every shape that reaches the port', () => { + expect(rawRequestPortReferences(probe, 'await client.sendRequest("worktree.ps", {})')).toBe(1) + expect(rawRequestPortReferences(probe, 'const send = client.sendRequest')).toBe(1) + expect(rawRequestPortReferences(probe, 'client["sendRequest"]("x")')).toBe(1) + expect(rawRequestPortReferences(probe, "type A = Pick")).toBe(1) + expect(rawRequestPortReferences(probe, "type A = RpcClient['sendRequest']")).toBe(1) + expect(rawRequestPortReferences(probe, 'const c = { sendRequest: async () => reply }')).toBe(1) + expect(rawRequestPortReferences(probe, 'interface C { sendRequest(m: string): void }')).toBe(1) + expect(rawRequestPortReferences(probe, "if (name === 'sendRequest') { }")).toBe(1) + expect( + rawRequestPortReferences(probe, 'await sendSingleFlightRequest(c, h, "worktree.ps")') + ).toBe(1) + expect( + rawRequestPortReferences( + probe, + "import { sendSingleFlightRequest } from './request-single-flight'" + ) + ).toBe(1) + expect( + rawRequestPortReferences( + probe, + "import type { UnvalidatedRpcRequestPort } from './unvalidated-rpc-request-port'" + ) + ).toBe(1) + expect( + rawRequestPortReferences( + probe, + "export type { SendRequestOptions } from './unvalidated-rpc-request-port'" + ) + ).toBe(1) + expect( + rawRequestPortReferences(probe, "const m = await import('./unvalidated-rpc-request-port')") + ).toBe(1) + expect(rawRequestPortReferences(probe, 'a.sendRequest(1); b.sendRequest(2)')).toBe(2) + }) + + it('does not count prose or an unrelated sender', () => { + expect(rawRequestPortReferences(probe, '// calls sendRequest under the hood')).toBe(0) + expect(rawRequestPortReferences(probe, '/* sendRequest */ export const x = 1')).toBe(0) + expect(rawRequestPortReferences(probe, 'await client.subscribe("terminal.stream", {})')).toBe(0) + expect(rawRequestPortReferences(probe, "import type { RpcClient } from './rpc-client'")).toBe(0) + expect(rawRequestPortReferences(probe, 'await runRpcOperation(client, op, {})')).toBe(0) + }) + + it('scans a plausible number of files', () => { + // A broken root or extension filter would make every check below vacuously pass. + expect(scanned.length).toBeGreaterThan(400) + expect(observed.size).toBeGreaterThan(50) + }) + + it('lists each file once', () => { + const seen = inventory.map((entry) => entry.file) + expect(seen.filter((file, index) => seen.indexOf(file) !== index)).toEqual([]) + }) + + it('has no unlisted file reaching the raw request port', () => { + const listed = new Set(inventory.map((entry) => entry.file)) + const unlisted = [...observed.keys()].filter((file) => !listed.has(file)) + expect( + unlisted, + 'New code must send through an RpcOperation. Nothing may be added to unvalidated-rpc-request-port-inventory.ts.' + ).toEqual([]) + }) + + it('has no stale inventory entry', () => { + const stale = inventory.filter((entry) => !observed.has(entry.file)) + expect( + stale.map((entry) => entry.file), + 'File no longer reaches the raw port — delete its line from unvalidated-rpc-request-port-inventory.ts.' + ).toEqual([]) + }) + + it('has no inventory entry whose file gained references', () => { + const grown = inventory + .filter((entry) => (observed.get(entry.file) ?? 0) > entry.references) + .map( + (entry) => `${entry.file}: listed ${entry.references}, found ${observed.get(entry.file)}` + ) + expect(grown, 'The counts are a ceiling. Send the new call through an RpcOperation.').toEqual( + [] + ) + }) + + it('reports a count that has fallen so the entry can be lowered', () => { + const overstated = inventory + .filter( + (entry) => observed.has(entry.file) && (observed.get(entry.file) ?? 0) < entry.references + ) + .map( + (entry) => `${entry.file}: listed ${entry.references}, found ${observed.get(entry.file)}` + ) + expect( + overstated, + 'Fewer references than listed — lower the count so the ratchet holds.' + ).toEqual([]) + }) +}) diff --git a/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts b/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts new file mode 100644 index 00000000000..82317f272fc --- /dev/null +++ b/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts @@ -0,0 +1,229 @@ +/** + * Every file that still reaches mobile's raw RPC request port, held as data. + * + * A reference is any direct reach for the port: a `.sendRequest` access or declaration, a + * `'sendRequest'` selector such as `Pick`, a call to the coalescing + * second sender `sendSingleFlightRequest`, or an import of unvalidated-rpc-request-port.ts. + * The count is per file and is a ceiling, not a target: unvalidated-rpc-request-port-boundary.test.ts + * fails on a file that is not listed, on a listed file that no longer reaches the port, and on a + * listed file whose count went up. Both lists only shrink. + * + * The owners are permanent — they implement, route or validate the port. The pending list is the + * step-4 migration backlog and shares one reason, stated once here instead of 144 times: + * the call site predates the typed contract and still picks its own method string, its own + * acceptance rule and its own decoding. Replacing one with an RpcOperation deletes its line. + */ +export type UnvalidatedRpcRequestPortEntry = { + readonly file: string + readonly references: number +} + +/** Modules whose job is the port. These do not shrink to zero. */ +export const UNVALIDATED_RPC_REQUEST_PORT_OWNERS: readonly UnvalidatedRpcRequestPortEntry[] = [ + // Implements the port over the device-to-host websocket. + { file: 'src/transport/direct-rpc-client.ts', references: 3 }, + // Fakes the port for the supervisor suites; a non-test file only because tsconfig excludes tests. + { file: 'src/transport/mobile-endpoint-supervisor-test-fakes.ts', references: 2 }, + // Implements the port over a relay channel. + { file: 'src/transport/mobile-relay-physical-client.ts', references: 2 }, + // Supplies the port for one relay session. + { file: 'src/transport/mobile-relay-rpc-session.ts', references: 1 }, + // A second raw sender: string method in, unread envelope out. Its callers are fenced too. + { file: 'src/transport/request-single-flight.ts', references: 3 }, + // Owns connect-wait, timeout and replay bookkeeping for every raw request. + { file: 'src/transport/rpc-client-request-tracker.ts', references: 1 }, + // Composes the port into RpcClient, which is why every holder of a client still carries it. + { file: 'src/transport/rpc-client.ts', references: 2 }, + // The typed boundary itself — the one module that turns a reply into a declared type. + { file: 'src/transport/rpc-operation.ts', references: 2 }, + // Forwards the port across a physical-client cutover. + { file: 'src/transport/stable-logical-rpc-client.ts', references: 2 } +] + +/** Call sites awaiting migration to a typed operation. Grouped by the feature area that owns them. */ +export const UNVALIDATED_RPC_REQUEST_PORT_PENDING: readonly UnvalidatedRpcRequestPortEntry[] = [ + // app/h/[hostId]/ — Expo route screens + { file: 'app/h/[hostId]/accounts.tsx', references: 2 }, + + // app/ — Expo route screens + { file: 'app/terminal-settings.tsx', references: 3 }, + + // src/agent-history/ — agent history loads + { file: 'src/agent-history/MobileAgentSessionHistoryPanel.tsx', references: 7 }, + { file: 'src/agent-history/use-mobile-agent-history-state.ts', references: 2 }, + + // src/browser/ — hosted browser control + { file: 'src/browser/use-mobile-browser-commands.ts', references: 5 }, + { file: 'src/browser/use-mobile-browser-request.ts', references: 1 }, + + // src/components/ — shared widgets that fetch their own data + { file: 'src/components/codex-reset-credit-capability.ts', references: 2 }, + { file: 'src/components/codex-reset-credit.ts', references: 3 }, + { file: 'src/components/use-new-workspace-create-submit.ts', references: 1 }, + { file: 'src/components/use-new-workspace-execution-target.ts', references: 4 }, + { file: 'src/components/use-new-workspace-repositories.ts', references: 1 }, + { file: 'src/components/use-new-workspace-runtime-context.ts', references: 4 }, + { file: 'src/components/use-new-workspace-setup-script.ts', references: 1 }, + + // src/dictation/ — dictation session control + { file: 'src/dictation/mobile-dictation-setup.ts', references: 10 }, + + // src/files/ — file read, write and preview + { file: 'src/files/mobile-file-mutation-ownership.ts', references: 3 }, + { file: 'src/files/mobile-file-preview-request.ts', references: 6 }, + { file: 'src/files/mobile-file-tab-doc.ts', references: 4 }, + { file: 'src/files/mobile-terminal-artifact-grant-refresh.ts', references: 2 }, + { file: 'src/files/MobileFileExplorerPanel.tsx', references: 2 }, + + // src/home/ — home screen host reads + { file: 'src/home/mobile-home-host-requests.ts', references: 6 }, + + // src/hooks/ — cross-screen data hooks + { file: 'src/hooks/mobile-dictation-audio-chunk.ts', references: 1 }, + { file: 'src/hooks/mobile-dictation-desktop-start.ts', references: 4 }, + { file: 'src/hooks/use-mobile-dictation.ts', references: 4 }, + + // src/host-screen/ — host screen catalog and actions + { file: 'src/host-screen/host-screen-overlays.tsx', references: 1 }, + { file: 'src/host-screen/use-host-repo-metadata.ts', references: 2 }, + { file: 'src/host-screen/use-host-view-settings.ts', references: 2 }, + { file: 'src/host-screen/use-host-worktree-actions.ts', references: 3 }, + + // src/notifications/ — push registration and delivery + { file: 'src/notifications/mobile-notifications.ts', references: 1 }, + { file: 'src/notifications/push-dismissal-reconciliation.ts', references: 2 }, + { file: 'src/notifications/push-registration.ts', references: 3 }, + + // src/session/ — session screen: chat, diff review, PR actions, tabs + { file: 'src/session/ai-vault-resume-launch.ts', references: 3 }, + { file: 'src/session/ai-vault-resume-preparation.ts', references: 2 }, + { file: 'src/session/github-pr-mutations.ts', references: 16 }, + { file: 'src/session/github-pr-rpc.ts', references: 9 }, + { file: 'src/session/mobile-clipboard-image.ts', references: 7 }, + { file: 'src/session/mobile-diff-review-loaders.ts', references: 5 }, + { file: 'src/session/mobile-file-tap-open.ts', references: 3 }, + { file: 'src/session/mobile-image-attachment.ts', references: 2 }, + { file: 'src/session/mobile-native-chat-image-attachment.ts', references: 1 }, + { file: 'src/session/mobile-native-chat-image-send.ts', references: 2 }, + { file: 'src/session/mobile-native-chat-send.ts', references: 2 }, + { file: 'src/session/mobile-native-chat-session-option-persistence.ts', references: 1 }, + { file: 'src/session/mobile-native-chat-stale-input.ts', references: 1 }, + { file: 'src/session/mobile-new-tab-agent-loader.ts', references: 5 }, + { file: 'src/session/mobile-session-tab-activation.ts', references: 3 }, + { file: 'src/session/mobile-session-tabs-stream-health.ts', references: 1 }, + { file: 'src/session/mobile-structured-agent-session-launch.ts', references: 3 }, + { file: 'src/session/mobile-structured-agent-session-rpc.ts', references: 1 }, + { file: 'src/session/pr-ai-triage-launch.ts', references: 3 }, + { file: 'src/session/use-live-worktree-name.ts', references: 1 }, + { file: 'src/session/use-mobile-diff-review-comment-actions.ts', references: 1 }, + { file: 'src/session/use-mobile-diff-review-git-actions.ts', references: 2 }, + { file: 'src/session/use-mobile-diff-review-interactions.ts', references: 1 }, + { file: 'src/session/use-mobile-diff-review-send-actions.ts', references: 3 }, + { file: 'src/session/use-mobile-file-tap-handlers.ts', references: 1 }, + { file: 'src/session/use-mobile-native-chat-file-search.ts', references: 2 }, + { file: 'src/session/use-mobile-native-chat-readability.ts', references: 1 }, + { file: 'src/session/use-mobile-native-chat-session.ts', references: 1 }, + { file: 'src/session/use-mobile-native-chat-stop.ts', references: 1 }, + { file: 'src/session/use-mobile-pr-actions.ts', references: 1 }, + { file: 'src/session/use-mobile-pr-branch-context.ts', references: 2 }, + { file: 'src/session/use-mobile-pr-comment-actions.ts', references: 1 }, + { file: 'src/session/use-mobile-pr-title-action.ts', references: 1 }, + { file: 'src/session/use-mobile-session-accessory-selection.ts', references: 1 }, + { file: 'src/session/use-mobile-session-close-actions.ts', references: 3 }, + { file: 'src/session/use-mobile-session-content-create-actions.ts', references: 4 }, + { file: 'src/session/use-mobile-session-diff-comments.ts', references: 2 }, + { file: 'src/session/use-mobile-session-document-readers.ts', references: 2 }, + { file: 'src/session/use-mobile-session-markdown-actions.ts', references: 1 }, + { file: 'src/session/use-mobile-session-startup.ts', references: 2 }, + { file: 'src/session/use-mobile-session-terminal-create-actions.ts', references: 2 }, + { file: 'src/session/use-mobile-session-terminal-input.ts', references: 2 }, + { file: 'src/session/use-mobile-session-terminal-list.ts', references: 1 }, + { file: 'src/session/use-mobile-session-terminal-send-actions.ts', references: 2 }, + { file: 'src/session/use-mobile-session-terminal-stream-display.ts', references: 1 }, + { file: 'src/session/use-mobile-terminal-paste.ts', references: 1 }, + { file: 'src/session/use-pr-bot-author-overrides.ts', references: 1 }, + { file: 'src/session/use-quick-commands.ts', references: 2 }, + + // src/settings/ — settings screen actions + { file: 'src/settings/native-voice-settings-operations.ts', references: 1 }, + + // src/settings/ — notification display probe + { file: 'src/settings/notification-display-test.tsx', references: 1 }, + + // src/source-control/ — source control: review, commit, branch + { file: 'src/source-control/mobile-branch-base-ref.ts', references: 3 }, + { file: 'src/source-control/mobile-commit-message-ai.ts', references: 4 }, + { file: 'src/source-control/mobile-git-history.ts', references: 2 }, + { file: 'src/source-control/mobile-hosted-review-create-intent-runner.ts', references: 1 }, + { file: 'src/source-control/mobile-hosted-review-create-intent.ts', references: 3 }, + { file: 'src/source-control/mobile-hosted-review-git-preparation.ts', references: 6 }, + { file: 'src/source-control/mobile-hosted-review-remote-prerequisite.ts', references: 1 }, + { file: 'src/source-control/mobile-hosted-review-service.ts', references: 8 }, + { file: 'src/source-control/mobile-pr-link.ts', references: 8 }, + { file: 'src/source-control/MobileGitHistoryList.tsx', references: 1 }, + { file: 'src/source-control/reveal-mobile-source-control-session-diff.ts', references: 2 }, + { file: 'src/source-control/use-mobile-git-requests.ts', references: 1 }, + { file: 'src/source-control/use-mobile-source-control-loaders.ts', references: 2 }, + { file: 'src/source-control/use-mobile-source-control-openers.ts', references: 3 }, + + // src/tasks/ — task lists, filters and mutations + { file: 'src/tasks/composer-source-base-resolve.ts', references: 2 }, + { file: 'src/tasks/mobile-tasks-filter-pickers.tsx', references: 1 }, + { file: 'src/tasks/mobile-tasks-source-family.test-support.ts', references: 1 }, + { file: 'src/tasks/setup-hook-trust.ts', references: 1 }, + { file: 'src/tasks/smart-source-paste-intent.ts', references: 4 }, + { file: 'src/tasks/smart-source-search-requests.ts', references: 5 }, + { file: 'src/tasks/use-mobile-tasks-client-settings-actions.tsx', references: 6 }, + { file: 'src/tasks/use-mobile-tasks-github-check-file-actions.tsx', references: 5 }, + { file: 'src/tasks/use-mobile-tasks-github-reply-merge-actions.tsx', references: 5 }, + { file: 'src/tasks/use-mobile-tasks-gitlab-github-status-actions.tsx', references: 3 }, + { file: 'src/tasks/use-mobile-tasks-hosted-comment-review-actions.tsx', references: 4 }, + { file: 'src/tasks/use-mobile-tasks-hosted-metadata-actions.tsx', references: 2 }, + { file: 'src/tasks/use-mobile-tasks-item-detail-loading.tsx', references: 4 }, + { file: 'src/tasks/use-mobile-tasks-item-detail-metadata-effects.tsx', references: 2 }, + { file: 'src/tasks/use-mobile-tasks-linear-item-actions.tsx', references: 3 }, + { file: 'src/tasks/use-mobile-tasks-list-and-detail-effects.tsx', references: 2 }, + { file: 'src/tasks/use-mobile-tasks-project-detail-loading.tsx', references: 1 }, + { file: 'src/tasks/use-mobile-tasks-project-file-merge-actions.tsx', references: 4 }, + { file: 'src/tasks/use-mobile-tasks-project-loading-actions.tsx', references: 4 }, + { file: 'src/tasks/use-mobile-tasks-project-metadata-actions.tsx', references: 3 }, + { file: 'src/tasks/use-mobile-tasks-project-metadata-loading.tsx', references: 3 }, + { file: 'src/tasks/use-mobile-tasks-project-repository-resolution.tsx', references: 1 }, + { file: 'src/tasks/use-mobile-tasks-project-review-check-actions.tsx', references: 4 }, + { file: 'src/tasks/use-mobile-tasks-project-thread-reply-actions.tsx', references: 4 }, + { file: 'src/tasks/use-mobile-tasks-project-workspace-comment-actions.tsx', references: 3 }, + { file: 'src/tasks/use-mobile-tasks-provider-load-actions.tsx', references: 5 }, + { file: 'src/tasks/use-mobile-tasks-route-and-item-state.tsx', references: 1 }, + { file: 'src/tasks/use-mobile-tasks-runtime-hydration.tsx', references: 5 }, + { file: 'src/tasks/use-mobile-tasks-task-create-actions.tsx', references: 3 }, + { file: 'src/tasks/use-mobile-tasks-task-list-loading.tsx', references: 4 }, + { file: 'src/tasks/use-mobile-tasks-task-pagination-actions.tsx', references: 1 }, + { file: 'src/tasks/use-mobile-tasks-workspace-create-actions.tsx', references: 4 }, + { file: 'src/tasks/use-mobile-tasks-workspace-source-effects.tsx', references: 2 }, + { file: 'src/tasks/use-mobile-tasks-workspace-sparse-actions.tsx', references: 2 }, + { file: 'src/tasks/use-mobile-tasks-workspace-ssh-state.tsx', references: 5 }, + { file: 'src/tasks/worktree-create-capability.ts', references: 1 }, + { file: 'src/tasks/worktree-create-retry.ts', references: 1 }, + + // src/terminal/ — terminal input, viewport and queries + { file: 'src/terminal/mobile-terminal-query-reply.ts', references: 2 }, + { file: 'src/terminal/terminal-live-accessory-raw-send.ts', references: 2 }, + { file: 'src/terminal/terminal-viewport-refit.ts', references: 1 }, + { file: 'src/terminal/worker-terminal-takeover-report.ts', references: 2 }, + + // src/transport/ — pairing, endpoint probing and capability reads + { file: 'src/transport/host-status-gates.ts', references: 1 }, + { file: 'src/transport/mobile-relay-credential-rotation.ts', references: 2 }, + { file: 'src/transport/mobile-relay-direct-upgrade.ts', references: 2 }, + { file: 'src/transport/mobile-relay-pairing-recovery.ts', references: 2 }, + { file: 'src/transport/mobile-runtime-capability-negotiation.ts', references: 2 }, + { file: 'src/transport/pairing-candidate-race.ts', references: 1 }, + { file: 'src/transport/pairing-relay-candidate.ts', references: 4 }, + { file: 'src/transport/pre-profile-pairing-coordinator.ts', references: 2 }, + { file: 'src/transport/runtime-capability-probe.ts', references: 2 }, + + // src/worktree/ — worktree activation and resume + { file: 'src/worktree/home-host-worktree-fetch.ts', references: 2 }, + { file: 'src/worktree/use-retired-worktree-names.ts', references: 1 }, + { file: 'src/worktree/worktree-catalog-snapshot-client.ts', references: 1 } +] diff --git a/mobile/src/transport/unvalidated-rpc-request-port.ts b/mobile/src/transport/unvalidated-rpc-request-port.ts new file mode 100644 index 00000000000..b626b290da6 --- /dev/null +++ b/mobile/src/transport/unvalidated-rpc-request-port.ts @@ -0,0 +1,31 @@ +import type { RpcResponse } from './types' + +// The raw request port, kept in its own module so that reaching it is a visible act. +// +// Nothing on this path is checked against the host contract: `method` is an unconstrained +// string, `params` is `unknown`, and the reply's `result` stays `unknown`. A value that came +// back through here has been parsed as JSON and nothing more, so it is NOT validated and must +// not be annotated as though it were. The typed boundary — defineRpcOperation and the send +// helpers in rpc-operation.ts — is the only path that turns a reply into a declared type, and +// rpc-operation.ts is the only module here that should be importing this one for that purpose. +// +// Every other file that still reaches this port is inventoried in +// unvalidated-rpc-request-port-inventory.ts and fenced by +// unvalidated-rpc-request-port-boundary.test.ts. That list only shrinks. + +export type SendRequestOptions = { + timeoutMs?: number + /** Include the connect wait in the caller's timeout budget. */ + budgetSpansConnect?: boolean + /** Reject instead of replaying the request after reconnect. */ + failWhenDisconnected?: boolean +} + +/** Unvalidated: an arbitrary method name in, an unread envelope out. */ +export type UnvalidatedRpcRequestPort = { + sendRequest: ( + method: string, + params?: unknown, + options?: SendRequestOptions + ) => Promise +} diff --git a/package.json b/package.json index 6bf087b987f..9cbae455f2d 100644 --- a/package.json +++ b/package.json @@ -164,7 +164,6 @@ }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "0.3.251", - "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", "@floating-ui/dom": "1.7.6", "@linear/sdk": "^82.1.0", @@ -178,12 +177,12 @@ "node-pty": "^1.1.0", "posthog-node": "^5.33.3", "proper-lockfile": "4.1.2", - "psl": "1.15.0", "qrcode": "^1.5.4", "react-i18next": "17.0.13", "serve-sim": "^0.1.40", "sherpa-onnx": "1.12.37", "ssh2": "^1.17.0", + "tldts": "7.4.12", "tweetnacl": "^1.0.3", "ws": "^8.21.3", "yaml": "^2.8.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7ea90a44bf..58b5d255c5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,9 +125,6 @@ importers: '@anthropic-ai/claude-agent-sdk': specifier: 0.3.251 version: 0.3.251(@anthropic-ai/sdk@0.122.0(zod@4.5.4))(@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@4.5.4))(zod@4.5.4) - '@electron-toolkit/preload': - specifier: ^3.0.2 - version: 3.0.2(electron@43.7.0(supports-color@7.2.0)) '@electron-toolkit/utils': specifier: ^4.0.0 version: 4.0.0(electron@43.7.0(supports-color@7.2.0)) @@ -167,9 +164,6 @@ importers: proper-lockfile: specifier: 4.1.2 version: 4.1.2 - psl: - specifier: 1.15.0 - version: 1.15.0 qrcode: specifier: ^1.5.4 version: 1.5.4 @@ -185,6 +179,9 @@ importers: ssh2: specifier: ^1.17.0 version: 1.17.0 + tldts: + specifier: 7.4.12 + version: 7.4.12 tweetnacl: specifier: ^1.0.3 version: 1.0.3 @@ -757,11 +754,6 @@ packages: resolution: {integrity: sha512-Zr1Vs7E9tpCNhZHDAbFVXc2gEVCG9RqPDjrno5+bdgB6LRAuvgyMHJut4NCVyYwtAieapMzc3fiQ3CSTi75ARg==} engines: {node: '>=22.12.0'} - '@electron-toolkit/preload@3.0.2': - resolution: {integrity: sha512-TWWPToXd8qPRfSXwzf5KVhpXMfONaUuRAZJHsKthKgZR/+LqX1dZVSSClQ8OTAEduvLGdecljCsoT2jSshfoUg==} - peerDependencies: - electron: '>=13.0.0' - '@electron-toolkit/tsconfig@2.0.0': resolution: {integrity: sha512-AdPsP770WhW7b260h13SHMdmjEEHJL6xFtgi3jwgdsSQbJOkJLeNnnpZW9qxTPCvmRI6vmdzWz5K3gibFS6SNg==} peerDependencies: @@ -5959,16 +5951,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} - psl@1.15.0: - resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} - pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - pvtsutils@1.3.6: resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} @@ -6613,10 +6598,17 @@ packages: tldts-core@7.4.10: resolution: {integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==} + tldts-core@7.4.12: + resolution: {integrity: sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==} + tldts@7.4.10: resolution: {integrity: sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==} hasBin: true + tldts@7.4.12: + resolution: {integrity: sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==} + hasBin: true + tmp-promise@3.0.3: resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} @@ -7332,10 +7324,6 @@ snapshots: '@electron-internal/extract-zip@1.0.4': {} - '@electron-toolkit/preload@3.0.2(electron@43.7.0(supports-color@7.2.0))': - dependencies: - electron: 43.7.0(supports-color@7.2.0) - '@electron-toolkit/tsconfig@2.0.0(@types/node@25.9.5)': dependencies: '@types/node': 25.9.5 @@ -12718,17 +12706,11 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 - psl@1.15.0: - dependencies: - punycode: 2.3.1 - pump@3.0.4: dependencies: end-of-stream: 1.4.5 once: 1.4.0 - punycode@2.3.1: {} - pvtsutils@1.3.6: dependencies: tslib: 2.8.1 @@ -13502,11 +13484,17 @@ snapshots: tldts-core@7.4.10: optional: true + tldts-core@7.4.12: {} + tldts@7.4.10: dependencies: tldts-core: 7.4.10 optional: true + tldts@7.4.12: + dependencies: + tldts-core: 7.4.12 + tmp-promise@3.0.3: dependencies: tmp: 0.2.7 diff --git a/src/main/ai-vault-search/session-search-engine-test-fixture.ts b/src/main/ai-vault-search/session-search-engine-test-fixture.ts new file mode 100644 index 00000000000..694a3d6397f --- /dev/null +++ b/src/main/ai-vault-search/session-search-engine-test-fixture.ts @@ -0,0 +1,113 @@ +import type { TranscriptMessageRole } from '../ai-vault/session-transcript-consumers' +import type SyncDatabase from '../sqlite/sync-database' +import { SessionSearchEngine, type SessionSearchEngineOptions } from './session-search-engine' +import { cwdKey } from './session-search-file-records' +import { identifierShadowText } from './session-search-identifier-split' +import { SessionSearchStore } from './session-search-store' +import { + openSessionSearchIndexFile, + type SessionSearchIndexFile +} from './session-search-index-test-fixture' + +// Synthetic index rows for the query tests. The write path has its own tests; +// driving it here would make every retrieval assertion depend on the parser. + +export type SessionSearchHarness = { + /** The engine's own connection; the store next to it keeps a second, private one. */ + db: SyncDatabase + /** A real writer on the same file, so a test can move the index under the engine. */ + store: SessionSearchStore + engine: SessionSearchEngine + close: () => Promise +} + +export async function openSessionSearchHarness( + name: string, + options: SessionSearchEngineOptions = {} +): Promise { + const index: SessionSearchIndexFile = await openSessionSearchIndexFile(name) + const store = new SessionSearchStore(index.path, (error) => { + throw error + }) + // Constructed before any row is planted, because constructing it is what + // installs the generation triggers the planted rows have to move. + const engine = new SessionSearchEngine(index.db, options) + return { + db: index.db, + store, + engine, + close: async () => { + store.close() + await index.close() + } + } +} + +export type SyntheticSession = { + id: number + cwd?: string | null + text?: string + /** Rows of `text` to write; one session with many rows is one hit. */ + rows?: number + role?: TranscriptMessageRole + /** + * Written into `tool_text` alongside `text`, which is the one row shape the + * conversation scope has to exclude while the `all` scope keeps it. + */ + toolText?: string + agent?: string + updatedAt?: string + messageCount?: number + /** Written into `files`, which is what makes the source `present`. */ + filePath?: string | null + /** `sessions.file_path`: the transcript `path:` searches alongside cwd. */ + sessionFilePath?: string +} + +/** One session and its message rows, in both FTS tables the way the writer does. */ +export function addSyntheticSession(db: SyncDatabase, session: SyntheticSession): void { + const { + id, + cwd = '/repo/app', + text = 'needle', + rows = 1, + role = 'user', + toolText = '', + agent = 'claude', + updatedAt = `2026-09-${String((id % 28) + 1).padStart(2, '0')}T00:00:00.000Z`, + messageCount = rows, + filePath = `/synthetic/${id}.jsonl`, + sessionFilePath = `/synthetic/${id}.jsonl` + } = session + db.prepare( + `INSERT INTO sessions(id,agent,session_id,file_path,title,cwd,cwd_key,updated_at,message_count,resume_command) + VALUES (?,?,?,?,'fixture',?,?,?,?,'resume')` + ).run(id, agent, String(id), sessionFilePath, cwd, cwdKey(cwd), updatedAt, messageCount) + if (filePath !== null) { + db.prepare( + 'INSERT INTO files(path,byte_offset,mtime_ms,session_row_id) VALUES (?,0,1740000000000,?)' + ).run(filePath, id) + } + for (let row = 0; row < rows; row++) { + const messageId = Number( + db + .prepare('INSERT INTO messages(session_row_id,role,ts) VALUES (?,?,?)') + .run(id, role, updatedAt).lastInsertRowid + ) + const user = role === 'user' ? text : '' + const assistant = role === 'assistant' ? text : '' + const tool = role === 'tool' ? `${text} ${toolText}`.trim() : toolText + db.prepare( + 'INSERT INTO messages_fts(rowid,user_text,assistant_text,tool_text,identifiers) VALUES (?,?,?,?,?)' + ).run(messageId, user, assistant, tool, identifierShadowText(`${text} ${toolText}`)) + } +} + +export function markFork(db: SyncDatabase, ids: readonly number[], hash: string): void { + for (const id of ids) { + db.prepare('UPDATE sessions SET content_hash = ?, content_hash_count = 8 WHERE id = ?').run( + hash, + id + ) + } +} diff --git a/src/main/ai-vault-search/session-search-engine-types.ts b/src/main/ai-vault-search/session-search-engine-types.ts new file mode 100644 index 00000000000..055bbe001af --- /dev/null +++ b/src/main/ai-vault-search/session-search-engine-types.ts @@ -0,0 +1,150 @@ +import type { AiVaultAgent } from '../../shared/ai-vault-types' +import type { TranscriptMessageRole } from '../ai-vault/session-transcript-consumers' + +// ENGINE types, deliberately not in src/shared: nothing here is a wire type. +// PR 5 owns the public contract and lifts what a caller may actually receive; +// until then a field can be added, renamed or dropped without a compat story. + +export const SESSION_SEARCH_LIMIT_DEFAULT = 20 +export const SESSION_SEARCH_LIMIT_MAX = 100 +// Longer than this is not a query, and FTS5 pays for every term it plans. +export const SESSION_SEARCH_QUERY_MAX_LENGTH = 512 + +// Snippet match markers. Why doubled: single brackets are everywhere in code +// transcripts (`arr[0]`, regex classes, markdown links) and would read as +// matches; doubled ones are rare. +export const SESSION_SEARCH_SNIPPET_MARK_OPEN = '[[' +export const SESSION_SEARCH_SNIPPET_MARK_CLOSE = ']]' + +/** + * Which corpus answers the query. + * + * - `conversation`: user and assistant turns only, as a column filter over + * `messages_fts` (see `scopedExpression`). + * - `all`: those turns plus tool calls and tool output, and the identifier + * shadow column, from `messages_fts`. + * + * The engine searches exactly the scope it is given. Switching corpus as the + * user types is a UI policy and lives in the panel (PR 7); an engine that + * second-guessed the scope would make a result impossible to reproduce from + * its own request. + */ +export type SessionSearchScope = 'conversation' | 'all' + +export type SessionSearchSort = 'relevance' | 'newest' + +export type SessionSearchFilters = { + agents?: readonly AiVaultAgent[] + /** Only sessions whose cwd is that path or inside it. */ + scopePaths?: readonly string[] + /** ISO timestamp; only sessions updated at or after it. */ + since?: string + sort?: SessionSearchSort +} + +export type SessionSearchRequest = { + query: string + /** Default `all`. */ + scope?: SessionSearchScope + limit?: number + /** From a previous response's `page.cursor`; only valid in its own generation. */ + cursor?: string + filters?: SessionSearchFilters +} + +export type SessionSearchRoute = 'phrase' | 'and' | 'or' | 'typo+phrase' | 'typo+and' | 'typo+or' + +/** + * How the query was executed. Diagnostics, not an answer: PR 5 decides which of + * these a caller ever sees (the reviewer's F5/F7 want them behind `debug`). + */ +export type SessionSearchPlannerReport = { + route: SessionSearchRoute + /** + * The whole body the repaired plan searched, in query order, when any term + * was changed. Not just the corrected terms: a caller rendering "searched + * for" needs the query it actually ran, and a repair never drops a term the + * original kept. A corrected term carries the index's own spelling, which the + * tokenizer has case-folded; untouched terms keep the case they were typed in. + */ + repairedTerms?: string[] + /** The corpus the route ran against; today always the requested scope. */ + tier: SessionSearchScope +} + +/** + * Where a source stands according to the index's own `files` table. The query + * path never stats a transcript, so it can report that the index has a live + * file record for a session or that it has none, and never that a source is + * gone: only a proven deletion may claim `missing`, and proving one is the + * indexer's job (docs/reference/ssh-execution-boundary.md). + */ +export type SessionSearchSourcePresence = 'present' | 'unverifiable' + +export type SessionSearchEvidence = { + role: TranscriptMessageRole + timestamp: string | null + /** FTS5 snippet with the matched terms wrapped in `[[` `]]`. */ + snippet: string + /** The snippet hit the engine's per-hit ceiling and was cut. */ + snippetTruncated?: boolean +} + +export type SessionSearchHit = { + agent: AiVaultAgent + sessionId: string + filePath: string + codexHome: string | null + title: string + cwd: string | null + branch: string | null + updatedAt: string | null + messageCount: number + resumeCommand: string + score: number + /** Sessions folded into this hit (forks sharing an opening prefix); absent when unique. */ + duplicateCount?: number + source: SessionSearchSourcePresence + /** Null when the operators alone put this session on the page, with no text match. */ + evidence: SessionSearchEvidence | null +} + +export type SessionSearchPage = { + /** Null when this page is the last one. */ + cursor: string | null + hasMore: boolean +} + +export type SessionSearchTruncation = { + /** + * Ranking saw only the first `sessionCandidateLimit` sessions, so a session + * past that cut cannot appear on any page of this query. + */ + candidates: boolean + /** Hits on this page whose snippet was cut. */ + snippets: number + /** + * The query itself was cut before it was searched: past the length ceiling, + * or past the number of terms the planner will plan. The terms that survived + * were searched in full, so a hit is still a hit; a miss is not proof of + * absence. + */ + query: boolean +} + +export type SessionSearchResponse = { + hits: SessionSearchHit[] + planner: SessionSearchPlannerReport + page: SessionSearchPage + truncated: SessionSearchTruncation + /** The index snapshot these hits came from; a cursor is only valid within it. */ + generation: number + durationMs: number +} + +export function resolveSessionSearchLimit(limit: number | undefined): number { + // Why clamped here and not at the caller: a non-positive limit becomes + // `slice(0, -1)`, which silently drops the last hit of every page. + const requested = Number.isInteger(limit) ? (limit as number) : SESSION_SEARCH_LIMIT_DEFAULT + return Math.min(Math.max(1, requested), SESSION_SEARCH_LIMIT_MAX) +} diff --git a/src/main/ai-vault-search/session-search-engine.test.ts b/src/main/ai-vault-search/session-search-engine.test.ts new file mode 100644 index 00000000000..6247144d4dc --- /dev/null +++ b/src/main/ai-vault-search/session-search-engine.test.ts @@ -0,0 +1,473 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { SESSION_SEARCH_QUERY_MAX_LENGTH } from './session-search-engine-types' +import type { SessionSearchRequest, SessionSearchResponse } from './session-search-engine-types' +import { planSessionSearchQuery } from './session-search-query-planner' +import { ensureSessionSearchQuerySchema } from './session-search-query-schema' +import { EMPTY_SNIPPET, sessionSearchSnippet } from './session-search-snippet' +import { + addSyntheticSession, + markFork, + openSessionSearchHarness, + type SessionSearchHarness +} from './session-search-engine-test-fixture' + +let harness: SessionSearchHarness | null = null + +afterEach(async () => { + await harness?.close() + harness = null +}) + +async function open(name: string, options = {}): Promise { + harness = await openSessionSearchHarness(name, options) + return harness +} + +function ids(result: SessionSearchResponse): string[] { + return result.hits.map((hit) => hit.sessionId) +} + +describe('the route ladder tries phrase, then AND, then repair, then OR', () => { + async function routeFor( + text: string, + request: SessionSearchRequest + ): Promise { + const { db, engine } = await open('ss-engine-route') + addSyntheticSession(db, { id: 1, text }) + return engine.search(request) + } + + it('takes the phrase route when the tokens are adjacent and in order', async () => { + const result = await routeFor('the alpha beta gamma line', { query: '"alpha beta"' }) + expect(result.planner.route).toBe('phrase') + expect(ids(result)).toEqual(['1']) + }) + + it('falls to AND when the tokens are present but not adjacent', async () => { + const result = await routeFor('beta separated alpha', { query: '"alpha beta"' }) + expect(result.planner.route).toBe('and') + expect(ids(result)).toEqual(['1']) + }) + + it('falls to OR for prose, where no phrase was ever claimed', async () => { + const result = await routeFor('the relay dropped a frame', { query: 'relay frames dropped' }) + expect(result.planner.route).toBe('or') + expect(ids(result)).toEqual(['1']) + }) + + it('repairs a typo before the OR fallback, and says which terms it changed', async () => { + const { db, engine } = await open('ss-engine-typo') + // Two copies: the repair only suggests a term the index really holds. + addSyntheticSession(db, { id: 1, text: 'the coalesces path is slow' }) + addSyntheticSession(db, { id: 2, text: 'coalesces again here' }) + const result = engine.search({ query: 'coalescs' }) + expect(result.planner.route).toBe('typo+or') + expect(result.planner.repairedTerms).toEqual(['coalesces']) + expect(ids(result).sort()).toEqual(['1', '2']) + }) + + it('keeps every term a repaired literal was typed with', async () => { + const { db, engine } = await open('ss-engine-typo-literal') + addSyntheticSession(db, { id: 1, text: 'parseJson the data' }) + addSyntheticSession(db, { id: 2, text: 'parseJson the data again' }) + // `parseJsonn(the, data)` is literal because of its punctuation; the + // corrected spelling read on its own is prose. Re-planning without carrying + // the original decision across would drop `the` and report a body that was + // never typed. + // A corrected term comes back in the index's own spelling, which unicode61 + // has folded; the terms the repair left alone keep the case they were typed. + const result = engine.search({ query: 'parseJsonn(the, data)' }) + expect(result.planner.repairedTerms).toEqual(['parsejson', 'the', 'data']) + }) + + it('does not repair a term the index already holds', async () => { + const { db, engine } = await open('ss-engine-no-typo') + addSyntheticSession(db, { id: 1, text: 'coalesces' }) + const result = engine.search({ query: 'coalesces' }) + expect(result.planner.repairedTerms).toBeUndefined() + expect(result.planner.route).toBe('or') + }) + + it('reports the scope it searched as the planner tier', async () => { + const { db, engine } = await open('ss-engine-tier') + addSyntheticSession(db, { id: 1, text: 'needle' }) + expect(engine.search({ query: 'needle' }).planner.tier).toBe('all') + expect(engine.search({ query: 'needle', scope: 'conversation' }).planner.tier).toBe( + 'conversation' + ) + }) +}) + +describe('scope picks the corpus and never switches it', () => { + async function corpus(): Promise { + const opened = await open('ss-engine-scope') + addSyntheticSession(opened.db, { id: 1, text: 'harbor pilot manifest', role: 'user' }) + addSyntheticSession(opened.db, { id: 2, text: 'harbor tool output line', role: 'tool' }) + return opened + } + + it('searches conversation turns only under `conversation`', async () => { + const { engine } = await corpus() + expect(ids(engine.search({ query: 'harbor', scope: 'conversation' }))).toEqual(['1']) + }) + + it('includes tool output under `all`, which is the default', async () => { + const { engine } = await corpus() + expect(ids(engine.search({ query: 'harbor', scope: 'all' })).sort()).toEqual(['1', '2']) + expect(ids(engine.search({ query: 'harbor' })).sort()).toEqual(['1', '2']) + }) + + it('returns nothing rather than widening when the narrow scope misses', async () => { + // The panel's two-tier typing is a UI policy (PR 7). An engine that widened + // here would make a result impossible to reproduce from its own request. + const { engine } = await corpus() + const result = engine.search({ query: 'output', scope: 'conversation' }) + expect(result.hits).toEqual([]) + expect(result.planner.tier).toBe('conversation') + }) + + it('matches an identifier through its pieces only in the full corpus', async () => { + const { db, engine } = await open('ss-engine-identifiers') + addSyntheticSession(db, { id: 1, text: 'resolveTerminalPath' }) + // The identifier shadow column lives in messages_fts alone. + expect(ids(engine.search({ query: 'terminal path' }))).toEqual(['1']) + expect(engine.search({ query: 'terminal path', scope: 'conversation' }).hits).toEqual([]) + }) +}) + +describe('the conversation scope is a column filter, and it binds the whole query', () => { + it('refuses an AND whose second term lives only in tool output', async () => { + // The filter binds to the expression it prefixes. `{cols}: (a AND b)` + // filters both terms; `{cols}: a AND b` filters only `a` and searches tool + // output for the rest, which is a conversation search answering from a + // column it promised not to read. + const { db, engine } = await open('ss-engine-scope-binding') + addSyntheticSession(db, { id: 1, text: 'alpha gamma beta' }) + addSyntheticSession(db, { id: 2, text: 'alpha gamma', toolText: 'beta' }) + // Quoted, so the query is literal; not adjacent, so the phrase rung misses + // and the AND rung is the one that answers. + const query = '"alpha" beta' + + const wide = engine.search({ query, scope: 'all' }) + expect(wide.planner.route).toBe('and') + expect(ids(wide).sort()).toEqual(['1', '2']) + + const narrowed = engine.search({ query, scope: 'conversation' }) + expect(narrowed.planner.route).toBe('and') + expect(ids(narrowed)).toEqual(['1']) + }) + + it('ranks a conversation hit down for tool output it will not show', async () => { + // The one behavioural difference the column filter carries, pinned rather + // than wished away. FTS5's bm25 normalises by the whole row's length and + // has no per-column length, so two rows with identical prose do not score + // identically when one of them also holds tool output. A dedicated + // two-column table scored them the same. The rowid set is unchanged, which + // is what the decision was measured on; the order within it can move. + const { db, engine } = await open('ss-engine-scope-weights') + addSyntheticSession(db, { id: 1, text: 'harbor pilot' }) + addSyntheticSession(db, { id: 2, text: 'harbor pilot', toolText: 'unrelated '.repeat(40) }) + const narrowed = engine.search({ query: 'harbor', scope: 'conversation' }) + expect(ids(narrowed)).toEqual(['1', '2']) + expect(narrowed.hits[0]!.score).toBeGreaterThan(narrowed.hits[1]!.score) + }) + + it('never snippets a conversation hit out of tool output', async () => { + const { db, engine } = await open('ss-engine-scope-snippet') + addSyntheticSession(db, { id: 1, text: 'harbor pilot', toolText: 'harbor tool output line' }) + const [hit] = engine.search({ query: 'harbor', scope: 'conversation' }).hits + expect(hit?.evidence?.snippet).toContain('pilot') + expect(hit?.evidence?.snippet).not.toContain('output') + // And asked for a tool-only row directly, it has nothing to show. + addSyntheticSession(db, { id: 2, text: 'harbor tool output line', role: 'tool' }) + const rowid = Number( + (db.prepare('SELECT max(id) AS id FROM messages').get() as { id: number }).id + ) + const plan = planSessionSearchQuery('harbor') + expect(sessionSearchSnippet(db, 'conversation', rowid, plan)).toEqual(EMPTY_SNIPPET) + expect(sessionSearchSnippet(db, 'all', rowid, plan).text).toContain('output') + }) +}) + +describe('a session is one hit, however many of its rows matched', () => { + it.each(['relevance', 'newest'] as const)( + 'keeps a short session on the %s page beside a 650-row session', + async (sort) => { + const { db, engine } = await open('ss-engine-aggregate', { sessionCandidateLimit: 600 }) + addSyntheticSession(db, { id: 1, rows: 650, updatedAt: '2026-09-06T00:00:00.000Z' }) + addSyntheticSession(db, { + id: 2, + text: 'needle padding', + updatedAt: '2026-09-05T00:00:00.000Z' + }) + // Collapsing to one row per session happens before the candidate limit, + // so the 650-row session cannot crowd the one-row session off the page on + // either order; which of them ranks first is the sort's business. + expect(ids(engine.search({ query: 'needle', filters: { sort } })).sort()).toEqual(['1', '2']) + } + ) + + it('folds forks the same way for an operator-only page as for a text page', async () => { + const { db, engine } = await open('ss-engine-forks') + for (const id of [1, 2, 3, 4]) { + addSyntheticSession(db, { id, updatedAt: `2026-09-0${id}T00:00:00.000Z` }) + } + markFork(db, [1, 2, 3, 4], 'shared-fork-prefix') + const operatorOnly = engine.search({ query: 'repo:app' }) + const withText = engine.search({ query: 'needle repo:app' }) + expect(ids(operatorOnly)).toEqual(['4']) + expect(operatorOnly.hits[0]?.duplicateCount).toBe(4) + expect(ids(withText)).toEqual(ids(operatorOnly)) + expect(withText.hits[0]?.duplicateCount).toBe(4) + }) + + it('answers an operator-only query with the newest sessions and no evidence', async () => { + const { db, engine } = await open('ss-engine-operator-only') + addSyntheticSession(db, { id: 1, updatedAt: '2026-09-01T00:00:00.000Z' }) + addSyntheticSession(db, { id: 2, updatedAt: '2026-09-09T00:00:00.000Z' }) + const result = engine.search({ query: 'repo:app' }) + expect(ids(result)).toEqual(['2', '1']) + expect(result.hits[0]?.evidence).toBeNull() + }) + + it('has no hits for a query with neither text nor operators', async () => { + const { db, engine } = await open('ss-engine-empty') + addSyntheticSession(db, { id: 1 }) + expect(engine.search({ query: ' ' }).hits).toEqual([]) + }) +}) + +describe('filters narrow retrieval, not just the page', () => { + it('finds a scoped match behind 600 out-of-scope rows', async () => { + const { db, engine } = await open('ss-engine-scoped') + addSyntheticSession(db, { id: 1, cwd: '/unrelated', rows: 600 }) + addSyntheticSession(db, { id: 2, cwd: '/target', text: 'needle padding' }) + expect(ids(engine.search({ query: 'needle', filters: { scopePaths: ['/target'] } }))).toEqual([ + '2' + ]) + }) + + it('falls back to a later rung when the exact hit is out of scope', async () => { + const { db, engine } = await open('ss-engine-scoped-route') + addSyntheticSession(db, { id: 1, cwd: '/unrelated', text: 'resolveTerminalPath' }) + addSyntheticSession(db, { id: 2, cwd: '/target', text: 'resolve terminal path' }) + expect( + ids(engine.search({ query: 'resolveTerminalPath', filters: { scopePaths: ['/target'] } })) + ).toEqual(['2']) + }) +}) + +describe('evidence', () => { + it('takes each snippet from that hit’s own best message', async () => { + const { db, engine } = await open('ss-engine-snippet') + // Written first, so its row owns the lowest rowid: the row a dropped rowid + // constraint would hand back for every hit. + addSyntheticSession(db, { + id: 1, + text: 'hydration marmoset appears once in a long paragraph about routing and caching', + updatedAt: '2026-09-01T00:00:00.000Z' + }) + addSyntheticSession(db, { + id: 2, + text: 'hydration capybara', + updatedAt: '2026-09-09T00:00:00.000Z' + }) + const hits = engine.search({ query: 'hydration' }).hits + expect(hits[0]?.evidence?.snippet).toContain('capybara') + expect(hits[0]?.evidence?.snippet).not.toContain('marmoset') + expect(hits.find((hit) => hit.sessionId === '1')?.evidence?.snippet).toContain('marmoset') + }) + + it('shows the prose column rather than the identifier shadow when both match', async () => { + const { db, engine } = await open('ss-engine-snippet-shadow') + addSyntheticSession(db, { + id: 1, + text: 'resolveTerminalPath is broken and the terminal never comes up for a pane, which is odd because every other pane on this host resolves its path' + }) + const snippet = engine.search({ query: 'terminal path' }).hits[0]?.evidence?.snippet ?? '' + expect(snippet).toContain('[[') + expect(snippet).not.toContain('resolve [[terminal]] [[path]]') + }) + + it('flags a snippet it had to cut, and counts it on the result', async () => { + const { db, engine } = await open('ss-engine-snippet-truncated') + // The window is twelve tokens wide, and one of them is 4000 characters, so + // the token count is no bound at all on what a hit carries. + addSyntheticSession(db, { id: 1, text: `needle ${'x'.repeat(4000)}` }) + const result = engine.search({ query: 'needle' }) + expect(result.hits[0]?.evidence?.snippetTruncated).toBe(true) + expect(result.hits[0]?.evidence?.snippet.length).toBeLessThan(600) + expect(result.truncated.snippets).toBe(1) + }) + + it('leaves an ordinary snippet unflagged', async () => { + const { db, engine } = await open('ss-engine-snippet-whole') + addSyntheticSession(db, { id: 1, text: 'needle in a short line' }) + const result = engine.search({ query: 'needle' }) + expect(result.hits[0]?.evidence?.snippetTruncated).toBeUndefined() + expect(result.truncated.snippets).toBe(0) + }) +}) + +describe('source presence comes from the files table, never a stat', () => { + it('calls a session with a live file record present', async () => { + const { db, engine } = await open('ss-engine-presence') + addSyntheticSession(db, { id: 1 }) + expect(engine.search({ query: 'needle' }).hits[0]?.source).toBe('present') + }) + + it('calls a session with no file record unverifiable, and still returns it', async () => { + // Loss of contact is never evidence of absence: the hit stays on the page. + const { db, engine } = await open('ss-engine-presence-unknown') + addSyntheticSession(db, { id: 1, filePath: null }) + const hits = engine.search({ query: 'needle' }).hits + expect(hits).toHaveLength(1) + expect(hits[0]?.source).toBe('unverifiable') + }) +}) + +describe('the engine carries its own schema and puts it back', () => { + it('installs the vocabulary over an index a writer built alone', async () => { + // The store creates none of these: PR 3's indexer can fill a whole index + // before anything opens an engine over it. + const { db, engine } = await open('ss-engine-installs') + addSyntheticSession(db, { id: 1, text: 'the coalesces path is slow' }) + addSyntheticSession(db, { id: 2, text: 'coalesces again here' }) + const result = engine.search({ query: 'coalescs' }) + expect(result.planner.route).toBe('typo+or') + expect(ids(result).sort()).toEqual(['1', '2']) + }) + + it('re-creates a vocabulary that vanished under a live engine', async () => { + const { db, engine } = await open('ss-engine-vocab-vanishes') + addSyntheticSession(db, { id: 1, text: 'coalesces here now' }) + addSyntheticSession(db, { id: 2, text: 'coalesces again here' }) + expect(engine.search({ query: 'coalescs' }).planner.route).toBe('typo+or') + + db.exec('DROP TABLE messages_vocab') + const after = engine.search({ query: 'coalescs' }) + expect(after.planner.route).toBe('typo+or') + }) + + it('fails clearly when the source index is missing', async () => { + const { db, engine } = await open('ss-engine-vocab-source-gone') + addSyntheticSession(db, { id: 1, text: 'coalesces here now', role: 'user' }) + db.exec('DROP TABLE messages_vocab; DROP TABLE messages_fts') + + expect(() => ensureSessionSearchQuerySchema(db)).toThrow('missing messages_fts') + for (const scope of ['all', 'conversation'] as const) { + expect(() => engine.search({ query: 'coalesces', scope })).toThrow(/missing messages_fts/i) + } + }) + + it('answers again after the source index is restored', async () => { + const { db, engine } = await open('ss-engine-vocab-returns') + addSyntheticSession(db, { id: 1, text: 'coalesces here now' }) + addSyntheticSession(db, { id: 2, text: 'coalesces again here' }) + const fts = ( + db.prepare("SELECT sql FROM sqlite_master WHERE name = 'messages_fts'").get() as { + sql: string + } + ).sql + db.exec('DROP TABLE messages_vocab; DROP TABLE messages_fts') + expect(() => ensureSessionSearchQuerySchema(db)).toThrow('missing messages_fts') + + db.exec(fts) + // Two, because the vocabulary only offers a term at least two rows carry. + addSyntheticSession(db, { id: 3, text: 'coalesces one more time' }) + addSyntheticSession(db, { id: 4, text: 'coalesces once again' }) + // Nothing throws on the way back up, so the recovery cannot come from the + // error path; it comes from the probe running per search. + const restored = engine.search({ query: 'coalescs' }) + expect(restored.planner.route).toBe('typo+or') + }) +}) + +describe('a query the engine had to cut says so', () => { + it('answers a query whose cap falls inside an astral character', async () => { + // The cut is on a whole code point rather than a code unit, so nothing + // downstream is handed half a surrogate pair. That is hygiene rather than a + // behaviour: the planner's tokenizer does not treat a lone surrogate as a + // token character, so it drops out of the terms either way. What this pins + // is that the boundary is answerable at all. + const { db, engine } = await open('ss-engine-surrogate-cap') + const kept = 'x'.repeat(SESSION_SEARCH_QUERY_MAX_LENGTH - 2) + addSyntheticSession(db, { id: 1, text: kept }) + const result = engine.search({ query: `${kept} 😀 tail` }) + expect(result.truncated.query).toBe(true) + expect(result.hits.map((hit) => hit.sessionId)).toEqual(['1']) + }) + + it('loads a candidate set larger than one batch of bound ids', async () => { + // The id list is as long as the candidate limit and every id is a bound + // parameter. No SQLite this stack can run refuses 1,100 of them, so this + // pins that batching returns the same answer, not that it rescues one. + const { db, engine } = await open('ss-engine-id-batching', { + sessionCandidateLimit: 1200 + }) + for (let id = 1; id <= 1100; id++) { + addSyntheticSession(db, { id, text: 'needle' }) + } + const result = engine.search({ query: 'needle', limit: 5 }) + expect(result.hits).toHaveLength(5) + expect(result.truncated.candidates).toBe(false) + }) + + it('reports truncation when the planner drops terms past its cap', async () => { + // The 56th term is the only one that matches. Without the flag this is a + // confident empty answer to a query the engine never finished reading. + const { db, engine } = await open('ss-engine-term-cap') + addSyntheticSession(db, { id: 1, text: 'onlyattheend' }) + const query = `${Array.from({ length: 55 }, (_unused, n) => `term${n}`).join(' ')} onlyattheend` + const result = engine.search({ query }) + expect(result.hits).toEqual([]) + expect(result.truncated.query).toBe(true) + }) + + it('reports truncation when the query is longer than the engine will plan', async () => { + const { db, engine } = await open('ss-engine-length-cap') + addSyntheticSession(db, { id: 1, text: 'needle' }) + const result = engine.search({ query: `needle ${'x'.repeat(SESSION_SEARCH_QUERY_MAX_LENGTH)}` }) + expect(result.truncated.query).toBe(true) + }) + + it('claims no truncation for a query that fit', async () => { + const { db, engine } = await open('ss-engine-no-cap') + addSyntheticSession(db, { id: 1, text: 'needle' }) + expect(engine.search({ query: 'needle' }).truncated.query).toBe(false) + }) +}) + +describe('a query longer than the engine will plan is cut, not refused', () => { + it('cuts one enormous token down to the cap before FTS5 ever sees it', async () => { + const { db, engine } = await open('ss-engine-long-query') + // The planner already caps how many terms it will plan, so a long query of + // ordinary words is bounded without this. What is not bounded is a single + // token: one 100 kB word is one term, and FTS5 would carry the whole thing + // into the MATCH expression. The cut is observable because the indexed + // token is exactly the capped length. + addSyntheticSession(db, { id: 1, text: 'x'.repeat(SESSION_SEARCH_QUERY_MAX_LENGTH) }) + expect(ids(engine.search({ query: 'x'.repeat(4000) }))).toEqual(['1']) + }) +}) + +describe('unicode terms survive the round trip', () => { + it.each(['café', 'C', 'R', 'x', '修復', '안녕하세요'])('searches %s', async (text) => { + const { db, engine } = await open('ss-engine-unicode') + addSyntheticSession(db, { id: 1, text }) + expect(engine.search({ query: text }).hits).toHaveLength(1) + }) +}) + +it.each(['repo:target', 'path:/work/target'])( + 'applies %s before selecting a route', + async (operator) => { + const { db, engine } = await open('ss-route-filter') + addSyntheticSession(db, { id: 1, cwd: '/work/other', text: 'alpha beta' }) + addSyntheticSession(db, { id: 2, cwd: '/work/target', text: 'alpha x beta' }) + const result = engine.search({ query: `"alpha beta" ${operator}` }) + expect(ids(result)).toEqual(['2']) + expect(result.planner.route).toBe('and') + expect(result.truncated.candidates).toBe(false) + } +) diff --git a/src/main/ai-vault-search/session-search-engine.ts b/src/main/ai-vault-search/session-search-engine.ts new file mode 100644 index 00000000000..ac7ddf9a3da --- /dev/null +++ b/src/main/ai-vault-search/session-search-engine.ts @@ -0,0 +1,259 @@ +import type SyncDatabase from '../sqlite/sync-database' +import type { TranscriptMessageRole } from '../ai-vault/session-transcript-consumers' +import { sliceAtCodeUnitLimit } from '../ai-vault/session-scanner-text-normalization' +import { + hasAiVaultSearchQueryOperators, + splitAiVaultSearchQuery, + type AiVaultSearchQuerySplit +} from '../../shared/ai-vault-search-query-operators' +import { matchesAiVaultQueryOperators } from '../../shared/ai-vault-session-filters' +import { + resolveSessionSearchLimit, + SESSION_SEARCH_QUERY_MAX_LENGTH, + type SessionSearchHit, + type SessionSearchRequest, + type SessionSearchResponse, + type SessionSearchScope, + type SessionSearchSourcePresence +} from './session-search-engine-types' +import { readIndexGeneration } from './session-search-index-generation' +import { + rankSessionHits, + type MessageRow, + type RankedSession, + type SessionRow +} from './session-search-hit-ranking' +import { + SessionSearchCursorError, + decodeSessionSearchCursor, + encodeSessionSearchCursor, + sessionSearchPageKey +} from './session-search-page-cursor' +import { planSessionSearchQuery } from './session-search-query-planner' +import { + SessionSearchRetrieval, + type RetrievalScope, + type Retrieved +} from './session-search-retrieval' +import { sessionRowFilter } from './session-search-row-filter' +import { ensureSessionSearchQuerySchema } from './session-search-query-schema' +import { EMPTY_SNIPPET, sessionSearchSnippet } from './session-search-snippet' +import { sessionSourcePresence } from './session-search-source-presence' + +/** + * Sessions retrieved before ranking cuts the page. + * + * Not a fixed constant (the reviewer's F13): it is the knob that trades page + * completeness for retrieval cost, and the right value depends on index size. + * Measurements behind this default, and what changing it costs, are in + * docs/reference/agent-session-search-query-tuning.md. + */ +export const SESSION_SEARCH_CANDIDATE_LIMIT_DEFAULT = 600 + +/** One ranked list plus what produced it; a page is a slice of `ranked`. */ +type RankedPage = { + ranked: RankedSession[] + /** Null when no text was searched, so there is nothing to snippet from. */ + retrieved: Retrieved | null + /** + * Retrieval may have missed a session: a cap ended it, not the data. True + * whether the candidate limit filled or the operator walk gave up scanning. + */ + incomplete: boolean +} + +export type SessionSearchEngineOptions = { + sessionCandidateLimit?: number + /** Oldest transcript mtime a hit may come from; PR 3 derives it from retention. */ + retentionCutoffMs?: number | null +} + +/** + * Synchronous searches use independent statements to avoid pinning the WAL. + * Generation checks bracket all content reads; concurrent writes reject the page. + * The connection's owner handles index rebuilds and engine reconstruction. + */ +export class SessionSearchEngine { + private readonly retrieval: SessionSearchRetrieval + private readonly candidateLimit: number + + constructor( + private readonly db: SyncDatabase, + private readonly options: SessionSearchEngineOptions = {} + ) { + this.candidateLimit = options.sessionCandidateLimit ?? SESSION_SEARCH_CANDIDATE_LIMIT_DEFAULT + // Installed here and not on the first search, so the generation triggers are + // watching before anything this engine will be asked to page over is + // written, and so retrieval below prepares against tables that exist. + ensureSessionSearchQuerySchema(this.db) + this.retrieval = new SessionSearchRetrieval(this.db) + } + + search(request: SessionSearchRequest): SessionSearchResponse { + const startedAt = performance.now() + ensureSessionSearchQuerySchema(this.db) + const generation = readIndexGeneration(this.db) + const scope = request.scope ?? 'all' + const sort = request.filters?.sort ?? 'relevance' + // Not a bare `slice`: cutting between a surrogate pair leaves a lone half + // that no tokenizer can match and that a caller cannot echo back. + const capped = sliceAtCodeUnitLimit(request.query, SESSION_SEARCH_QUERY_MAX_LENGTH) + const split = splitAiVaultSearchQuery(capped) + const retrievalScope: RetrievalScope = { + scope, + sort, + filter: sessionRowFilter(request.filters ?? {}, this.options.retentionCutoffMs ?? null), + matchesOperators: operatorPredicate(split), + candidateLimit: this.candidateLimit + } + // Decoded before any retrieval: a cursor the engine will refuse must not + // cost a query, and the caller has to hear about it either way. + const pageKey = sessionSearchPageKey(request) + const offset = request.cursor + ? decodeSessionSearchCursor(request.cursor, generation, pageKey) + : 0 + + const plan = planSessionSearchQuery(split.text) + const { ranked, retrieved, incomplete } = + plan.terms.length === 0 + ? this.operatorOnly(split, retrievalScope) + : this.text(plan, retrievalScope, sort) + + const limit = resolveSessionSearchLimit(request.limit) + const page = ranked.slice(offset, offset + limit) + const hits = this.hits(page, scope, retrieved) + const actualGeneration = readIndexGeneration(this.db) + if (actualGeneration !== generation) { + throw new SessionSearchCursorError('stale-generation', actualGeneration, generation) + } + const hasMore = ranked.length > offset + limit + const response: SessionSearchResponse = { + hits, + planner: { + route: retrieved?.route ?? 'or', + tier: scope, + ...(retrieved?.repairedTerms ? { repairedTerms: retrieved.repairedTerms } : {}) + }, + page: { + hasMore, + cursor: hasMore ? encodeSessionSearchCursor(generation, offset + limit, pageKey) : null + }, + truncated: { + // Decided by retrieval, which is the only layer that knows whether a cap + // ended it. Deriving it from the hits cannot work: an operator walk that + // gave up at its scan ceiling returns no hits, and so does a search that + // genuinely matched nothing. + candidates: incomplete, + snippets: hits.filter((hit) => hit.evidence?.snippetTruncated).length, + query: capped.length < request.query.length || plan.truncated + }, + generation, + durationMs: performance.now() - startedAt + } + return response + } + + /** + * Operators with no free text still name a scope, so the answer is the newest + * sessions inside it. Ranked through the same path as a text query, because + * forks must fold here exactly as they do there or the same sessions answer + * `repo:x` and `word repo:x` differently. There is no relevance signal + * without text, so the order is always newest. + */ + private operatorOnly(split: AiVaultSearchQuerySplit, scope: RetrievalScope): RankedPage { + if (!hasAiVaultSearchQueryOperators(split)) { + return { ranked: [], retrieved: null, incomplete: false } + } + const { sessions, incomplete } = this.retrieval.recent(scope) + return { ranked: rankSessionHits(sessions, new Map(), 'newest'), retrieved: null, incomplete } + } + + private text( + plan: ReturnType, + scope: RetrievalScope, + sort: 'relevance' | 'newest' + ): RankedPage { + const retrieved = this.retrieval.run(plan, scope) + // `match` already grouped to one best row per session. + const best = new Map(retrieved.rows.map((row) => [row.session_row_id, row])) + return { + ranked: rankSessionHits(retrieved.sessions, best, sort), + retrieved, + incomplete: retrieved.incomplete + } + } + + /** Snippets and source presence are paid for by the page, never by the list. */ + private hits( + page: readonly RankedSession[], + scope: SessionSearchScope, + retrieved: Retrieved | null + ): SessionSearchHit[] { + const presence = sessionSourcePresence( + this.db, + page.map((entry) => entry.session.id) + ) + return page.map((entry) => this.hit(entry, scope, retrieved, presence)) + } + + private hit( + entry: RankedSession, + scope: SessionSearchScope, + retrieved: Retrieved | null, + presence: ReadonlyMap + ): SessionSearchHit { + const { session, message } = entry + const snippet = + message && retrieved + ? sessionSearchSnippet(this.db, scope, message.rowid, retrieved.plan) + : EMPTY_SNIPPET + return { + ...sessionFields(session), + score: entry.score, + ...(entry.duplicateCount > 1 ? { duplicateCount: entry.duplicateCount } : {}), + source: presence.get(session.id) ?? 'unverifiable', + evidence: message + ? { + role: message.role as TranscriptMessageRole, + timestamp: message.ts, + snippet: snippet.text, + ...(snippet.truncated ? { snippetTruncated: true } : {}) + } + : null + } + } +} + +/** + * The one reading of `repo:` / `path:`: the sessions panel's own predicate, over + * the columns the index stores. The engine has no project map, so a session's + * repo label falls back to its folder label, which is what the panel does for + * every session it cannot resolve a project for. + */ +function operatorPredicate(split: AiVaultSearchQuerySplit): (session: SessionRow) => boolean { + if (!hasAiVaultSearchQueryOperators(split)) { + return () => true + } + return (session) => + matchesAiVaultQueryOperators( + { cwd: session.cwd, filePath: session.file_path }, + { repoTerms: split.repoTerms, pathTerms: split.pathTerms } + ) +} + +function sessionFields( + session: SessionRow +): Omit { + return { + agent: session.agent, + sessionId: session.session_id, + filePath: session.file_path, + codexHome: session.codex_home, + title: session.title, + cwd: session.cwd, + branch: session.branch, + updatedAt: session.updated_at, + messageCount: session.message_count, + resumeCommand: session.resume_command + } +} diff --git a/src/main/ai-vault-search/session-search-fts5-contract.test.ts b/src/main/ai-vault-search/session-search-fts5-contract.test.ts new file mode 100644 index 00000000000..be815623ce7 --- /dev/null +++ b/src/main/ai-vault-search/session-search-fts5-contract.test.ts @@ -0,0 +1,172 @@ +import { mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { removeTree } from '../../shared/windows-transient-lock-removal' +import type SyncDatabase from '../sqlite/sync-database' +import { indexTokens } from './session-search-query-planner' +import { ensureSessionSearchQuerySchema } from './session-search-query-schema' +import { openSessionSearchDatabase } from './session-search-schema' + +// SQLite/FTS5 behaviours the query layer depends on. Each one cost a live +// debugging session; a refactor that reintroduces the trap fails here. + +const FIRST_ROWID = 101 +const SECOND_ROWID = 202 + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => removeTree(root))) + tempRoots = [] +}) + +async function openDatabase(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-fts5-contract-')) + tempRoots.push(root) + return openSessionSearchDatabase(join(root, 'index.sqlite')) +} + +function insertMessageRow(db: SyncDatabase, rowid: number, text: string): void { + db.prepare( + `INSERT INTO messages_fts(rowid, user_text, assistant_text, tool_text, identifiers) + VALUES (?, ?, '', '', '')` + ).run(rowid, text) +} + +describe('FTS5 aux functions take the table name, never an alias', () => { + it('rejects bm25 over an aliased table and accepts the table-name form', async () => { + const db = await openDatabase() + insertMessageRow(db, FIRST_ROWID, 'alpha marmoset one') + + expect(() => + db.prepare('SELECT bm25(f) AS score FROM messages_fts f WHERE f MATCH ?').all('alpha') + ).toThrow(/no such column: f/) + + const scored = db + .prepare('SELECT bm25(messages_fts) AS score FROM messages_fts WHERE messages_fts MATCH ?') + .all('alpha') as { score: number }[] + expect(scored).toHaveLength(1) + expect(Number.isFinite(scored[0]?.score)).toBe(true) + db.close() + }) + + it('rejects snippet over an aliased table too', async () => { + const db = await openDatabase() + insertMessageRow(db, FIRST_ROWID, 'alpha marmoset one') + + expect(() => + db + .prepare( + "SELECT snippet(f, -1, '[', ']', '…', 12) AS s FROM messages_fts f WHERE f MATCH ?" + ) + .all('alpha') + ).toThrow(/no such column: f/) + db.close() + }) +}) + +describe('a rowid constraint beside MATCH is honoured only as a subselect', () => { + it('ignores `rowid = ?` and returns every match, first row first', async () => { + const db = await openDatabase() + insertMessageRow(db, FIRST_ROWID, 'alpha marmoset one') + insertMessageRow(db, SECOND_ROWID, 'alpha capybara two') + + const rows = db + .prepare('SELECT rowid FROM messages_fts WHERE messages_fts MATCH ? AND rowid = ?') + .all('alpha', SECOND_ROWID) as { rowid: number }[] + // The planner drops the constraint entirely: both rows come back. + expect(rows.map((row) => row.rowid)).toEqual([FIRST_ROWID, SECOND_ROWID]) + // A caller reading one row therefore gets the first match, not the one asked for. + const single = db + .prepare('SELECT rowid FROM messages_fts WHERE messages_fts MATCH ? AND rowid = ?') + .get('alpha', SECOND_ROWID) as { rowid: number } | undefined + expect(single?.rowid).toBe(FIRST_ROWID) + db.close() + }) + + it('ignores `rowid IN (?)` the same way', async () => { + const db = await openDatabase() + insertMessageRow(db, FIRST_ROWID, 'alpha marmoset one') + insertMessageRow(db, SECOND_ROWID, 'alpha capybara two') + + const rows = db + .prepare('SELECT rowid FROM messages_fts WHERE messages_fts MATCH ? AND rowid IN (?)') + .all('alpha', SECOND_ROWID) as { rowid: number }[] + expect(rows.map((row) => row.rowid)).toEqual([FIRST_ROWID, SECOND_ROWID]) + db.close() + }) + + it('honours `rowid IN (SELECT ?)` even with the session join on', async () => { + const db = await openDatabase() + db.prepare( + `INSERT INTO sessions(id,agent,session_id,file_path,title,resume_command) + VALUES (1,'claude','1','/synthetic/1','fixture','')` + ).run() + for (const rowid of [FIRST_ROWID, SECOND_ROWID]) { + db.prepare("INSERT INTO messages(id,session_row_id,role) VALUES (?,1,'user')").run(rowid) + } + insertMessageRow(db, FIRST_ROWID, 'alpha marmoset one') + insertMessageRow(db, SECOND_ROWID, 'alpha capybara two') + + // The shape the snippet read uses: the joins are what subtract a row whose + // session a purge cut loose, and they must not cost the rowid constraint + // its effect. + const snippet = db + .prepare( + `SELECT snippet(messages_fts, -1, '[', ']', '…', 12) AS s + FROM messages_fts + JOIN messages m ON m.id = messages_fts.rowid + JOIN sessions s ON s.id = m.session_row_id + WHERE messages_fts MATCH ? AND messages_fts.rowid IN (SELECT ?)` + ) + .get('alpha', SECOND_ROWID) as { s: string } | undefined + expect(snippet?.s).toContain('capybara') + expect(snippet?.s).not.toContain('marmoset') + db.close() + }) +}) + +describe('sessions.file_path is deliberately not unique', () => { + it('accepts two sessions sharing one store path', async () => { + const db = await openDatabase() + const insert = db.prepare( + `INSERT INTO sessions(agent, session_id, file_path, title, resume_command) + VALUES (?, ?, ?, ?, ?)` + ) + // OpenCode and Cursor keep every session in one SQLite store; files.path is the key. + const storePath = '/home/user/.local/share/opencode/storage.db' + insert.run('opencode', 'ses_one', storePath, 'first', 'opencode --session ses_one') + expect(() => + insert.run('opencode', 'ses_two', storePath, 'second', 'opencode --session ses_two') + ).not.toThrow() + + const rows = db + .prepare('SELECT session_id FROM sessions WHERE file_path = ? ORDER BY session_id') + .all(storePath) as { session_id: string }[] + expect(rows.map((row) => row.session_id)).toEqual(['ses_one', 'ses_two']) + db.close() + }) +}) + +describe('the planner tokenizer draws the same boundaries as unicode61', () => { + // unicode61 folds case and strips Latin diacritics on both index and query side. + function asIndexed(token: string): string { + return token.toLowerCase().normalize('NFD').replaceAll(/\p{M}/gu, '') + } + + it('produces exactly the terms fts5vocab reports for the same text', async () => { + const db = await openDatabase() + // The vocabulary is the engine's own object, not the store's. + ensureSessionSearchQuerySchema(db) + const corpus = + 'resolveTerminalPath src/main/foo-bar.ts a.b C++ #123 修复 café naïve MAX_TOKEN x' + insertMessageRow(db, FIRST_ROWID, corpus) + const indexed = ( + db.prepare('SELECT term FROM messages_vocab ORDER BY term').all() as { term: string }[] + ).map((row) => row.term) + + expect([...new Set(indexTokens(corpus).map(asIndexed))].sort()).toEqual(indexed) + db.close() + }) +}) diff --git a/src/main/ai-vault-search/session-search-hit-ranking.test.ts b/src/main/ai-vault-search/session-search-hit-ranking.test.ts new file mode 100644 index 00000000000..54919bace0f --- /dev/null +++ b/src/main/ai-vault-search/session-search-hit-ranking.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { rankSessionHits, type MessageRow, type SessionRow } from './session-search-hit-ranking' + +function session(id: number, overrides: Partial = {}): SessionRow { + return { + id, + agent: 'claude', + session_id: String(id), + file_path: `/synthetic/${id}.jsonl`, + codex_home: null, + title: 'fixture', + cwd: '/repo/app', + branch: null, + updated_at: '2026-09-01T00:00:00.000Z', + message_count: 1, + resume_command: 'resume', + content_hash: null, + content_hash_count: 0, + ...overrides + } +} + +function match(id: number, score: number): MessageRow { + return { rowid: id, score, session_row_id: id, role: 'user', ts: null } +} + +function matches(...rows: MessageRow[]): Map { + return new Map(rows.map((row) => [row.session_row_id, row])) +} + +describe('order', () => { + it('ranks by score under relevance and by recency under newest', () => { + const sessions = [ + session(1, { updated_at: '2026-09-01T00:00:00.000Z' }), + session(2, { updated_at: '2026-09-09T00:00:00.000Z' }) + ] + const scores = matches(match(1, 10), match(2, 1)) + expect(rankSessionHits(sessions, scores, 'relevance').map((e) => e.session.id)).toEqual([1, 2]) + expect(rankSessionHits(sessions, scores, 'newest').map((e) => e.session.id)).toEqual([2, 1]) + }) + + it.each(['relevance', 'newest'] as const)( + 'breaks a %s tie by session, whatever order retrieval handed them over in', + (sort) => { + // A cursor is an offset into this list, so two entries that tie must not + // be free to swap between pages. Retrieval hands sessions over in + // whatever order the `IN (...)` lookup produced, which SQL does not + // promise, so the order below is deliberately reversed. + const sessions = [6, 5, 4, 3, 2, 1].map((id) => session(id)) + const scores = matches(...sessions.map((entry) => match(entry.id, 5))) + expect(rankSessionHits(sessions, scores, sort).map((entry) => entry.session.id)).toEqual([ + 1, 2, 3, 4, 5, 6 + ]) + } + ) + + it('prefers the shorter session when two match equally well', () => { + // The length prior: `0.02 · ln(1 + messages)`, subtracted per session. + const sessions = [session(1, { message_count: 5000 }), session(2, { message_count: 2 })] + const ranked = rankSessionHits(sessions, matches(match(1, 5), match(2, 5)), 'relevance') + expect(ranked.map((entry) => entry.session.id)).toEqual([2, 1]) + expect(ranked[0]!.score).toBeGreaterThan(ranked[1]!.score) + }) +}) + +describe('forks fold into one answer', () => { + const fork = (id: number, updatedAt: string): SessionRow => + session(id, { + updated_at: updatedAt, + content_hash: 'shared-opening-prefix', + content_hash_count: 8 + }) + + it('keeps the newest copy and counts the rest', () => { + const sessions = [ + fork(1, '2026-09-01T00:00:00.000Z'), + fork(2, '2026-09-09T00:00:00.000Z'), + fork(3, '2026-09-05T00:00:00.000Z') + ] + const ranked = rankSessionHits( + sessions, + matches(match(1, 9), match(2, 1), match(3, 5)), + 'relevance' + ) + expect(ranked).toHaveLength(1) + expect(ranked[0]!.session.id).toBe(2) + expect(ranked[0]!.duplicateCount).toBe(3) + }) + + it('leaves sessions with no shared prefix alone', () => { + const sessions = [session(1), session(2)] + const ranked = rankSessionHits(sessions, matches(match(1, 9), match(2, 5)), 'relevance') + expect(ranked.map((entry) => entry.duplicateCount)).toEqual([1, 1]) + }) +}) + +it('scores a session that matched no text at zero, less its length prior', () => { + // The operator-only page: there is no relevance signal, only an order. + const ranked = rankSessionHits([session(1, { message_count: 9 })], new Map(), 'newest') + expect(ranked[0]!.message).toBeNull() + expect(ranked[0]!.score).toBeLessThan(0) +}) diff --git a/src/main/ai-vault-search/session-search-hit-ranking.ts b/src/main/ai-vault-search/session-search-hit-ranking.ts new file mode 100644 index 00000000000..364858ea650 --- /dev/null +++ b/src/main/ai-vault-search/session-search-hit-ranking.ts @@ -0,0 +1,109 @@ +import type { AiVaultAgent } from '../../shared/ai-vault-types' +import { isCollapsibleContentHash } from './session-search-content-hash' +import type { SessionSearchSort } from './session-search-engine-types' + +// Subtracted per session: `0.02 · ln(1 + messages)`; slightly positive on both eval sets. +const LENGTH_PRIOR = 0.02 + +export type SessionRow = { + id: number + agent: AiVaultAgent + session_id: string + file_path: string + codex_home: string | null + title: string + cwd: string | null + branch: string | null + updated_at: string | null + message_count: number + resume_command: string + content_hash: string | null + content_hash_count: number +} + +/** The one message that stands for a session: its best-scoring match. */ +export type MessageRow = { + rowid: number + score: number + session_row_id: number + role: string + ts: string | null +} + +export type RankedSession = { + session: SessionRow + /** Null on an operator-only page: the session matched no text at all. */ + message: MessageRow | null + score: number + duplicateCount: number +} + +/** + * Everything between "these sessions matched" and "this is the ranked list": + * the length prior, fork folding and the caller's order. Retrieval stays in SQL + * and nothing here touches the database. + * + * The whole list is returned, not a page: a cursor indexes into it, and slicing + * here would make page two a different ranking from page one. The engine cuts + * the page and only then pays for a snippet. + */ +export function rankSessionHits( + sessions: readonly SessionRow[], + matches: ReadonlyMap, + sort: SessionSearchSort +): RankedSession[] { + const scored = collapseForks( + sessions.map((session) => { + const message = matches.get(session.id) ?? null + return { + session, + message, + score: (message?.score ?? 0) - LENGTH_PRIOR * Math.log(1 + session.message_count), + duplicateCount: 1 + } + }) + ) + // Why a total order and not just the key: a cursor is an offset into this + // list, so two entries that tie must not be free to swap between pages. + scored.sort( + (left, right) => + (sort === 'newest' + ? (right.session.updated_at ?? '').localeCompare(left.session.updated_at ?? '') + : right.score - left.score) || left.session.id - right.session.id + ) + return scored +} + +/** + * Folds forked copies of one conversation into a single entry: same opening + * prefix, newest `updated_at` wins, the rest become `duplicateCount`. Done here + * and not at write time so index rows stay per file (cursors and deletes). + */ +function collapseForks(scored: RankedSession[]): RankedSession[] { + const groups = new Map() + for (const entry of scored) { + const { content_hash: hash, content_hash_count: count, id } = entry.session + const key = isCollapsibleContentHash(hash, count) ? `hash:${hash}` : `session:${id}` + const group = groups.get(key) + if (group) { + group.push(entry) + } else { + groups.set(key, [entry]) + } + } + const collapsed: RankedSession[] = [] + for (const group of groups.values()) { + if (group.length === 1) { + collapsed.push(group[0]!) + continue + } + const winner = group.reduce((best, entry) => (isNewer(entry, best) ? entry : best)) + collapsed.push({ ...winner, duplicateCount: group.length }) + } + return collapsed +} + +function isNewer(entry: RankedSession, best: RankedSession): boolean { + const order = (entry.session.updated_at ?? '').localeCompare(best.session.updated_at ?? '') + return order === 0 ? entry.score > best.score : order > 0 +} diff --git a/src/main/ai-vault-search/session-search-index-generation.test.ts b/src/main/ai-vault-search/session-search-index-generation.test.ts new file mode 100644 index 00000000000..f728759c0e4 --- /dev/null +++ b/src/main/ai-vault-search/session-search-index-generation.test.ts @@ -0,0 +1,329 @@ +import { appendFile, mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { removeTree } from '../../shared/windows-transient-lock-removal' +import type SyncDatabase from '../sqlite/sync-database' +import { SessionSearchEngine } from './session-search-engine' +import { readIndexGeneration } from './session-search-index-generation' +import { registerSessionSearchIndexConsumer } from './session-search-index-consumer' +import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache' +import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers' +import type { SessionSearchCursorError } from './session-search-page-cursor' +import { openSessionSearchDatabase } from './session-search-schema' +import { SessionSearchStore } from './session-search-store' +import { parseTranscript, userRecord } from './session-search-transcript-fixtures' + +let roots: string[] = [] +let handles: SyncDatabase[] = [] + +afterEach(async () => { + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + for (const handle of handles) { + handle.close() + } + handles = [] + await Promise.all(roots.map((root) => removeTree(root))) + roots = [] +}) + +async function tempRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-search-generation-')) + roots.push(root) + return root +} + +/** + * A reader's own handle on the index, with the engine's schema installed. + * + * PR 2's store keeps its connection private, so a reader opens its own — which + * is what the fence has to survive: nothing this handle does moves the + * generation, and it must still see every writer's move. + */ +function reader(path: string): SyncDatabase { + const db = openSessionSearchDatabase(path) + handles.push(db) + // Constructing an engine is what installs the triggers. + new SessionSearchEngine(db) + return db +} + +/** Indexes one transcript through the real consumer and returns its path. */ +async function indexOneTranscript(root: string, store: SessionSearchStore): Promise { + resetSessionParseCacheForTests() + const sessionId = `aaaaaaaa-0000-4000-8000-${String(roots.length).padStart(12, '0')}` + const path = join(root, `${Math.random().toString(36).slice(2)}.jsonl`) + await writeFile(path, `${userRecord(0, 'generation fixture needle', sessionId)}\n`) + const unregister = registerSessionSearchIndexConsumer(store) + try { + await parseTranscript(path) + } finally { + unregister() + } + return path +} + +it('moves the generation forward when a committed read changes what a read returns', async () => { + const root = await tempRoot() + const path = join(root, 'index.sqlite') + const db = reader(path) + const store = new SessionSearchStore(path, (error) => { + throw error + }) + try { + const before = readIndexGeneration(db) + await indexOneTranscript(root, store) + expect(readIndexGeneration(db)).toBeGreaterThan(before) + } finally { + store.close() + } +}) + +it('moves the generation forward when an append adds rows to a live session', async () => { + // The first read of a file inserts its `files` row; every read after that + // updates it. An append changes a session's rank and its message count, so a + // cursor minted before it indexes into a list that no longer exists. + const root = await tempRoot() + const path = join(root, 'index.sqlite') + const db = reader(path) + const store = new SessionSearchStore(path, (error) => { + throw error + }) + try { + const transcript = await indexOneTranscript(root, store) + const indexed = readIndexGeneration(db) + const unregister = registerSessionSearchIndexConsumer(store) + try { + resetSessionParseCacheForTests() + await appendFile(transcript, `${userRecord(1, 'a second needle turn')}\n`) + await parseTranscript(transcript) + } finally { + unregister() + } + expect(db.prepare('SELECT COUNT(*) AS c FROM messages').get()).toEqual({ c: 2 }) + expect(readIndexGeneration(db)).toBeGreaterThan(indexed) + } finally { + store.close() + } +}) + +it('moves the generation forward when a proven deletion hides a session', async () => { + const root = await tempRoot() + const path = join(root, 'index.sqlite') + const db = reader(path) + const store = new SessionSearchStore(path, (error) => { + throw error + }) + try { + const transcript = await indexOneTranscript(root, store) + const indexed = readIndexGeneration(db) + store.removeFile(transcript) + expect(readIndexGeneration(db)).toBeGreaterThan(indexed) + } finally { + store.close() + } +}) + +it('moves the generation forward when retention cuts a session loose', async () => { + // Retention deletes the session row and the file row in one transaction, then + // reclaims the messages over many. It is the first half that changes what a + // search returns, and the first half that has to move the generation. + const root = await tempRoot() + const path = join(root, 'index.sqlite') + const db = reader(path) + const store = new SessionSearchStore(path, (error) => { + throw error + }) + try { + await indexOneTranscript(root, store) + const indexed = readIndexGeneration(db) + await store.purgeOlderThan(Date.now() + 60_000) + expect(db.prepare('SELECT COUNT(*) AS c FROM sessions').get()).toEqual({ c: 0 }) + expect(readIndexGeneration(db)).toBeGreaterThan(indexed) + } finally { + store.close() + } +}) + +it('moves the generation when a purge reclaims rows nothing can reach', async () => { + // The drain writes only `messages`, and for a while that was argued to change + // no answer. Retrieval never saw those rows; the typo repair's dictionary + // did, because `messages_vocab` is a view over the FTS b-tree and lists a + // term whether or not a reader can reach it. See + // `session-search-orphan-rows.test.ts` for the answer that moved. The price + // of fencing it is a cursor refused once per batch while a purge runs. + const root = await tempRoot() + const path = join(root, 'index.sqlite') + const db = reader(path) + const store = new SessionSearchStore(path, (error) => { + throw error + }) + try { + await indexOneTranscript(root, store) + // The shape an interrupted purge leaves: rows with no session row. + db.prepare('DELETE FROM sessions').run() + const orphaned = readIndexGeneration(db) + expect(db.prepare('SELECT COUNT(*) AS c FROM messages').get()).not.toEqual({ c: 0 }) + await store.purgeOlderThan(null) + expect(db.prepare('SELECT COUNT(*) AS c FROM messages').get()).toEqual({ c: 0 }) + expect(readIndexGeneration(db)).toBeGreaterThan(orphaned) + } finally { + store.close() + } +}) + +it("leaves the generation alone when a replace swaps a session's own rows", async () => { + // The same trigger must not fire here, or every re-read of a large transcript + // would move the generation once per deleted row on top of the one bump its + // file record already makes. A replace deletes rows whose session row still + // stands, which is what the trigger's `WHEN` clause tests. + const root = await tempRoot() + const path = join(root, 'index.sqlite') + const db = reader(path) + const store = new SessionSearchStore(path, (error) => { + throw error + }) + try { + await indexOneTranscript(root, store) + const rows = db.prepare('SELECT COUNT(*) AS c FROM messages').get() as { c: number } + const indexed = readIndexGeneration(db) + db.prepare('DELETE FROM messages WHERE session_row_id IN (SELECT id FROM sessions)').run() + expect(rows.c).toBeGreaterThan(0) + expect(readIndexGeneration(db)).toBe(indexed) + } finally { + store.close() + } +}) + +it('leaves the generation alone when a removal hides nothing', async () => { + // A backfill retires paths it never held; if that moved the generation, every + // cursor would be refused for as long as indexing ran. + const root = await tempRoot() + const path = join(root, 'index.sqlite') + const db = reader(path) + const store = new SessionSearchStore(path, (error) => { + throw error + }) + try { + await indexOneTranscript(root, store) + const before = readIndexGeneration(db) + store.removeFile('/synthetic/never-indexed.jsonl') + expect(readIndexGeneration(db)).toBe(before) + } finally { + store.close() + } +}) + +it('keeps the generation across a reopen, because the bump rides its own commit', async () => { + // The bump is inside the transaction that changes visibility, so nothing can + // be lost to a crash and reopening need not invalidate anyone's cursor. + const root = await tempRoot() + const path = join(root, 'index.sqlite') + reader(path) + const first = new SessionSearchStore(path, (error) => { + throw error + }) + await indexOneTranscript(root, first) + const indexed = readIndexGeneration(reader(path)) + first.close() + + const second = new SessionSearchStore(path) + try { + expect(readIndexGeneration(reader(path))).toBe(indexed) + } finally { + second.close() + } +}) + +it('fences a reader against a writer it does not share a process with', async () => { + // The shape PR 3 creates: the indexer writes from the scanner child while an + // engine reads elsewhere. A generation cached in the reader's memory tracks + // only that reader's own writes, so it would stand still through the + // writer's deletion, honour the stale cursor, and skip a session. + const root = await tempRoot() + const path = join(root, 'index.sqlite') + const db = reader(path) + const writer = new SessionSearchStore(path, (error) => { + throw error + }) + try { + const transcripts: string[] = [] + for (let n = 0; n < 3; n++) { + transcripts.push(await indexOneTranscript(root, writer)) + } + const engine = new SessionSearchEngine(db) + const page = engine.search({ query: 'needle', limit: 1 }) + expect(page.page.cursor).not.toBeNull() + + writer.removeFile(transcripts[0]!) + + // The reader never wrote anything, and must still refuse. + try { + engine.search({ query: 'needle', limit: 1, cursor: page.page.cursor! }) + expect.unreachable('a page cursor must not survive another writer moving the index') + } catch (error) { + expect((error as SessionSearchCursorError).rejection).toBe('stale-generation') + } + } finally { + writer.close() + } +}) + +it('re-creates a fence something dropped, on the next search', async () => { + // An index whose triggers are gone cannot move its generation, so every stale + // cursor would compare equal and be honoured against a list the caller never + // saw. The engine owns those triggers, so it puts them back. + const root = await tempRoot() + const path = join(root, 'index.sqlite') + const db = reader(path) + const store = new SessionSearchStore(path, (error) => { + throw error + }) + try { + const transcript = await indexOneTranscript(root, store) + const engine = new SessionSearchEngine(db) + db.exec('DROP TRIGGER search_generation_file_update') + engine.search({ query: 'needle' }) + + expect( + db + .prepare("SELECT name FROM sqlite_master WHERE type = 'trigger' AND name = ?") + .get('search_generation_file_update') + ).toEqual({ name: 'search_generation_file_update' }) + + // An UPDATE of the row that already exists, because that is the trigger + // this dropped: re-indexing a transcript also inserts and deletes, so it + // moves the generation whether or not the dropped one came back. + const restored = readIndexGeneration(db) + db.exec(`UPDATE files SET mtime_ms = mtime_ms + 1 WHERE path = '${transcript}'`) + expect(readIndexGeneration(db)).toBeGreaterThan(restored) + } finally { + store.close() + } +}) + +it('mints a distinct generation per change even when two handles write', async () => { + const root = await tempRoot() + const path = join(root, 'index.sqlite') + const db = reader(path) + const first = new SessionSearchStore(path, (error) => { + throw error + }) + const second = new SessionSearchStore(path, (error) => { + throw error + }) + try { + const seen: number[] = [readIndexGeneration(db)] + for (const store of [first, second, first, second]) { + await indexOneTranscript(root, store) + seen.push(readIndexGeneration(db)) + } + // Read-then-write from two connections would hand out one value twice. + expect(new Set(seen).size).toBe(seen.length) + expect([...seen].sort((left, right) => left - right)).toEqual(seen) + } finally { + second.close() + first.close() + } +}) diff --git a/src/main/ai-vault-search/session-search-index-generation.ts b/src/main/ai-vault-search/session-search-index-generation.ts new file mode 100644 index 00000000000..ee10eb0e295 --- /dev/null +++ b/src/main/ai-vault-search/session-search-index-generation.ts @@ -0,0 +1,42 @@ +import type SyncDatabase from '../sqlite/sync-database' + +const GENERATION_KEY = 'index_generation' + +export const SESSION_SEARCH_GENERATION_TRIGGERS = [ + 'search_generation_file_insert', + 'search_generation_file_update', + 'search_generation_file_delete', + 'search_generation_orphan_reclaim' +] as const + +const BUMP = `INSERT INTO meta(key, value) VALUES ('${GENERATION_KEY}', '1') + ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + 1;` + +/** + * Triggers commit the generation with writes from any connection. + * Orphan reclamation also changes the vocabulary used for typo suggestions. + */ +export const SESSION_SEARCH_GENERATION_SQL = ` +CREATE TRIGGER IF NOT EXISTS search_generation_file_insert AFTER INSERT ON files BEGIN + ${BUMP} +END; +CREATE TRIGGER IF NOT EXISTS search_generation_file_update AFTER UPDATE ON files BEGIN + ${BUMP} +END; +CREATE TRIGGER IF NOT EXISTS search_generation_file_delete AFTER DELETE ON files BEGIN + ${BUMP} +END; +CREATE TRIGGER IF NOT EXISTS search_generation_orphan_reclaim AFTER DELETE ON messages +WHEN NOT EXISTS (SELECT 1 FROM sessions WHERE id = OLD.session_row_id) BEGIN + ${BUMP} +END; +` + +/** Read the committed generation on each check, including other processes' writes. */ +export function readIndexGeneration(db: SyncDatabase): number { + const row = db.prepare('SELECT value FROM meta WHERE key = ?').get(GENERATION_KEY) as + | { value: string } + | undefined + const parsed = row ? Number(row.value) : Number.NaN + return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0 +} diff --git a/src/main/ai-vault-search/session-search-orphan-rows.test.ts b/src/main/ai-vault-search/session-search-orphan-rows.test.ts new file mode 100644 index 00000000000..dfda2303104 --- /dev/null +++ b/src/main/ai-vault-search/session-search-orphan-rows.test.ts @@ -0,0 +1,186 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type SyncDatabase from '../sqlite/sync-database' +import { + addSyntheticSession, + openSessionSearchHarness, + type SessionSearchHarness +} from './session-search-engine-test-fixture' +import { identifierShadowText } from './session-search-identifier-split' +import { readIndexGeneration } from './session-search-index-generation' +import { planSessionSearchQuery } from './session-search-query-planner' +import { sessionSearchSnippet } from './session-search-snippet' +import type { SessionSearchCursorError } from './session-search-page-cursor' +import { SessionSearchTypoRepair } from './session-search-typo-repair' + +// Retention deletes a session row in one small transaction and reclaims its +// message rows in batches afterwards, so a `messages` row with no `sessions` row +// is a state every purge, every removed source and every interrupted drain +// passes through. Those rows are still in both FTS tables and still in the +// vocabulary, and nothing here may return one. +// +// A hit is a session row, and the ranked list is loaded `FROM sessions`, so the +// route ladder below cannot surface an orphan even if a join were loosened — +// those cases are a ratchet over the shape, not the proof. The two reads that +// can leak one are pinned separately and each is a real oracle: the snippet, +// which is handed a rowid and asked for its text, and the typo repair, whose +// dictionary is the FTS b-tree and lists an orphan's terms like any other. + +const ORPHAN_SESSION_ROW = 99 +const ORPHAN_TEXT = 'orphaned marmoset secret' + +let harness: SessionSearchHarness | null = null + +afterEach(async () => { + await harness?.close() + harness = null +}) + +/** Two rows in the FTS table and the vocabulary, and no session row for them. */ +function plantOrphans(db: SyncDatabase, text: string = ORPHAN_TEXT): number[] { + const rowids: number[] = [] + for (let n = 0; n < 2; n++) { + const rowid = Number( + db + .prepare("INSERT INTO messages(session_row_id,role,ts) VALUES (?,'user',?)") + .run(ORPHAN_SESSION_ROW, '2026-09-10T00:00:00.000Z').lastInsertRowid + ) + db.prepare( + 'INSERT INTO messages_fts(rowid,user_text,assistant_text,tool_text,identifiers) VALUES (?,?,?,?,?)' + ).run(rowid, text, '', '', identifierShadowText(text)) + rowids.push(rowid) + } + return rowids +} + +async function withOrphans(): Promise<{ harness: SessionSearchHarness; rowids: number[] }> { + harness = await openSessionSearchHarness('ss-orphan-rows') + addSyntheticSession(harness.db, { id: 1, text: 'the haystack line here' }) + const rowids = plantOrphans(harness.db) + // The oracle only means anything if the rows are really there to be found. + expect( + harness.db + .prepare("SELECT count(*) AS c FROM messages_fts WHERE messages_fts MATCH 'marmoset'") + .get() + ).toEqual({ c: 2 }) + expect( + harness.db.prepare("SELECT doc FROM messages_vocab WHERE term = 'marmoset'").get() + ).toEqual({ doc: 2 }) + return { harness, rowids } +} + +it.each([ + ['phrase', '"orphaned marmoset"'], + ['and', 'orphaned secret'], + ['single-token literal', 'marmoset'], + ['or', 'marmoset haystack orphaned'], + ['typo repair', 'marmosett'], + ['operator only', 'repo:app'] +])('returns no orphaned row on the %s route', async (_route, query) => { + const { harness: open } = await withOrphans() + for (const scope of ['all', 'conversation'] as const) { + const hits = open.engine.search({ query, scope }).hits + expect(hits.map((hit) => hit.sessionId)).not.toContain(String(ORPHAN_SESSION_ROW)) + expect(hits.filter((hit) => hit.evidence?.snippet.includes('marmoset'))).toEqual([]) + } +}) + +it('never repairs a term onto a spelling only orphaned rows carry', async () => { + const { harness: open } = await withOrphans() + // `marmoset` is in the vocabulary twice, which is what would make it the + // repair for `marmosett` if the repair trusted the vocabulary alone. + expect(new SessionSearchTypoRepair(open.db).correct('marmosett', 'all')).toBeNull() + expect(open.engine.search({ query: 'marmosett' }).planner.repairedTerms).toBeUndefined() +}) + +it('snippets nothing for an orphaned row, even asked for it by rowid', async () => { + const { harness: open, rowids } = await withOrphans() + const plan = planSessionSearchQuery('marmoset') + for (const scope of ['all', 'conversation'] as const) { + expect(sessionSearchSnippet(open.db, scope, rowids[0]!, plan)).toEqual({ + text: '', + truncated: false + }) + } +}) + +it('still answers for the live session beside them', async () => { + const { harness: open } = await withOrphans() + expect(open.engine.search({ query: 'haystack' }).hits.map((hit) => hit.sessionId)).toEqual(['1']) +}) + +// Reclaiming those rows is the other half. The drain deletes only from +// `messages`, so for a long time it was argued to change no answer and left +// outside the generation fence. Retrieval never saw them, but the typo repair's +// dictionary is `messages_vocab`, a view over the FTS b-tree that lists a term +// whether or not a reader can reach the rows carrying it — so the drain moved +// which word a query was repaired to, under a cursor that was still honoured. +describe('a purge reclaiming rows nothing can reach', () => { + /** A live session and a purged one that both carry `text`. */ + async function withReclaimable(): Promise { + harness = await openSessionSearchHarness('ss-orphan-drain') + // Two live rows, which is what makes `marmoset` eligible as a repair at all. + addSyntheticSession(harness.db, { id: 1, text: 'the marmoset lives here', rows: 2 }) + plantOrphans(harness.db) + return harness + } + + it('answers the same before and after, because the repair counts live rows', async () => { + const open = await withReclaimable() + const before = open.engine.search({ query: 'marmosett' }) + expect(before.planner.repairedTerms).toEqual(['marmoset']) + expect(before.hits.map((hit) => hit.sessionId)).toEqual(['1']) + + await open.store.purgeOlderThan(null) + expect(open.db.prepare('SELECT count(*) AS c FROM messages').get()).toEqual({ c: 2 }) + + const after = open.engine.search({ query: 'marmosett' }) + expect(after.planner.repairedTerms).toEqual(before.planner.repairedTerms) + expect(after.hits.map((hit) => hit.sessionId)).toEqual(before.hits.map((hit) => hit.sessionId)) + }) + + it('moves the generation anyway, so no cursor spans it', async () => { + // The repair counting live rows fixes the common case. It does not make the + // drain provably inert: `messages_vocab` still decides which candidates + // survive its scan limit, and reclaiming a term's last row changes where + // that limit cuts. The fence is what covers the rest, at the price of + // refusing a cursor once per batch while a purge runs. + const open = await withReclaimable() + // A second live session, so page one has a page two to be refused. + addSyntheticSession(open.db, { id: 2, text: 'the marmoset again', rows: 2 }) + const page = open.engine.search({ query: 'marmoset', limit: 1 }) + expect(page.page.cursor).not.toBeNull() + const before = readIndexGeneration(open.db) + + await open.store.purgeOlderThan(null) + + expect(readIndexGeneration(open.db)).toBeGreaterThan(before) + try { + open.engine.search({ query: 'marmoset', limit: 1, cursor: page.page.cursor! }) + expect.unreachable('a cursor must not span a purge') + } catch (error) { + expect((error as SessionSearchCursorError).rejection).toBe('stale-generation') + } + }) + + it('picks the same repair when an unreachable spelling was the more common one', async () => { + // Two candidates equally close to the query. `marmosetx` led on the old + // ranking only because two of its rows belonged to a session retention had + // already cut loose, so the drain swapped the repair under a live cursor. + harness = await openSessionSearchHarness('ss-orphan-drain-tie') + const db = harness.db + for (let id = 1; id <= 4; id++) { + addSyntheticSession(db, { id, text: `marmosetx session${id}` }) + } + for (let id = 5; id <= 9; id++) { + addSyntheticSession(db, { id, text: `marmosetq session${id}` }) + } + plantOrphans(db, 'marmosetx') + + const before = harness.engine.search({ query: 'marmosett' }) + expect(before.planner.repairedTerms).toEqual(['marmosetq']) + await harness.store.purgeOlderThan(null) + expect(harness.engine.search({ query: 'marmosett' }).planner.repairedTerms).toEqual( + before.planner.repairedTerms + ) + }) +}) diff --git a/src/main/ai-vault-search/session-search-page-cursor.ts b/src/main/ai-vault-search/session-search-page-cursor.ts new file mode 100644 index 00000000000..30e07fc9a6e --- /dev/null +++ b/src/main/ai-vault-search/session-search-page-cursor.ts @@ -0,0 +1,99 @@ +import { createHash } from 'node:crypto' +import type { SessionSearchRequest } from './session-search-engine-types' + +export type SessionSearchCursorRejection = 'stale-generation' | 'different-query' | 'malformed' + +/** Rejects invalid cursors or any page whose generation changes during its reads. */ +export class SessionSearchCursorError extends Error { + constructor( + readonly rejection: SessionSearchCursorRejection, + /** The generation observed when rejecting the request. */ + readonly actualGeneration: number, + /** Cursor generation, or the generation at the start of a first-page read. */ + readonly expectedGeneration?: number + ) { + super(`Search page rejected: ${rejection}`) + this.name = 'SessionSearchCursorError' + } +} + +type CursorPayload = { + /** Index generation. */ + g: number + /** + * Offset into the ranked list, not a session id. Ids are not in a cursor at + * all, so nothing here depends on `sessions.id` being unique over time — + * though it is, because PR 2 made the column AUTOINCREMENT so a purged + * session's id is never reissued to a live one. + */ + o: number + /** Query identity; see `sessionSearchPageKey`. */ + k: string +} + +/** + * Everything a page's ranking depends on except the limit. Two requests with + * the same key produce the same ranked list within one generation, so a cursor + * minted by one is meaningful to the other; the limit is left out on purpose so + * a caller may change its page size mid-pagination. + */ +export function sessionSearchPageKey(request: SessionSearchRequest): string { + const filters = request.filters ?? {} + const identity = JSON.stringify([ + request.query, + request.scope ?? 'all', + filters.sort ?? 'relevance', + filters.since ?? null, + [...(filters.agents ?? [])].sort(), + [...(filters.scopePaths ?? [])].sort() + ]) + return createHash('sha256').update(identity).digest('base64url').slice(0, 16) +} + +export function encodeSessionSearchCursor(generation: number, offset: number, key: string): string { + const payload: CursorPayload = { g: generation, o: offset, k: key } + return Buffer.from(JSON.stringify(payload), 'utf-8').toString('base64url') +} + +/** + * The offset this cursor points at, or a typed rejection. + * + * Every rejection carries `actualGeneration`, and every one that could read a + * generation out of the cursor carries `expectedGeneration` too, so a caller + * can tell "the index moved under you, ask for page one" from "this cursor is + * not ours" and act on the first without showing anyone an error. + */ +export function decodeSessionSearchCursor(cursor: string, generation: number, key: string): number { + let payload: CursorPayload + try { + payload = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf-8')) as CursorPayload + } catch { + throw new SessionSearchCursorError('malformed', generation) + } + // A generation that survived parsing is worth reporting even when the rest of + // the payload is unusable: it is what tells the caller which snapshot the + // cursor thought it was walking. + // A counter, so a fraction or a negative is forged rather than stale. + const claimed = + typeof payload?.g === 'number' && Number.isInteger(payload.g) && payload.g >= 0 + ? payload.g + : undefined + if ( + claimed === undefined || + !Number.isInteger(payload?.o) || + payload.o < 0 || + typeof payload?.k !== 'string' + ) { + throw new SessionSearchCursorError('malformed', generation, claimed) + } + // Generation first: a caller who changed the query AND waited through a + // publish should hear about the index moving, which is the condition it + // cannot fix by paging again. + if (claimed !== generation) { + throw new SessionSearchCursorError('stale-generation', generation, claimed) + } + if (payload.k !== key) { + throw new SessionSearchCursorError('different-query', generation, claimed) + } + return payload.o +} diff --git a/src/main/ai-vault-search/session-search-paging.test.ts b/src/main/ai-vault-search/session-search-paging.test.ts new file mode 100644 index 00000000000..31341853e28 --- /dev/null +++ b/src/main/ai-vault-search/session-search-paging.test.ts @@ -0,0 +1,351 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SessionSearchRequest } from './session-search-engine-types' +import { + addSyntheticSession, + openSessionSearchHarness, + type SessionSearchHarness +} from './session-search-engine-test-fixture' +import { readIndexGeneration } from './session-search-index-generation' +import { + decodeSessionSearchCursor, + encodeSessionSearchCursor, + SessionSearchCursorError, + sessionSearchPageKey +} from './session-search-page-cursor' + +let harness: SessionSearchHarness | null = null + +afterEach(async () => { + await harness?.close() + harness = null +}) + +async function open(name: string, options = {}): Promise { + harness = await openSessionSearchHarness(name, options) + return harness +} + +async function withSessions(count: number, options = {}): Promise { + harness = await openSessionSearchHarness('ss-engine-paging', options) + for (let id = 1; id <= count; id++) { + addSyntheticSession(harness.db, { + id, + text: `needle padding ${'word '.repeat(id % 5)}`, + updatedAt: `2026-09-${String(id).padStart(2, '0')}T00:00:00.000Z` + }) + } + return harness +} + +describe('a cursor walks one ranked list', () => { + it('pages through every session exactly once, in one stable order', async () => { + const { engine } = await withSessions(25) + const request: SessionSearchRequest = { query: 'needle', limit: 10 } + const seen: string[] = [] + let cursor: string | null = null + let pages = 0 + do { + const page = engine.search(cursor ? { ...request, cursor } : request) + seen.push(...page.hits.map((hit) => hit.sessionId)) + cursor = page.page.cursor + pages++ + expect(pages).toBeLessThan(10) + } while (cursor !== null) + + expect(pages).toBe(3) + expect(seen).toHaveLength(25) + expect(new Set(seen).size).toBe(25) + // The same walk, run again against the same generation, is the same walk. + expect(engine.search(request).hits.map((hit) => hit.sessionId)).toEqual(seen.slice(0, 10)) + }) + + it('closes the page when the last hit has been handed out', async () => { + const { engine } = await withSessions(3) + const page = engine.search({ query: 'needle', limit: 10 }) + expect(page.hits).toHaveLength(3) + expect(page.page.hasMore).toBe(false) + expect(page.page.cursor).toBeNull() + }) + + it('lets a caller change page size mid-walk', async () => { + const { engine } = await withSessions(12) + const first = engine.search({ query: 'needle', limit: 5 }) + const rest = engine.search({ query: 'needle', limit: 20, cursor: first.page.cursor! }) + expect(rest.hits).toHaveLength(7) + expect(rest.page.hasMore).toBe(false) + }) + + it('breaks a tie by session, so two entries cannot swap between pages', async () => { + // Same text, same timestamp: every ranking key is equal, which is exactly + // where an unstable sort would hand one session out twice and lose another. + harness = await openSessionSearchHarness('ss-engine-ties') + for (let id = 1; id <= 6; id++) { + addSyntheticSession(harness.db, { id, text: 'needle', updatedAt: '2026-09-01T00:00:00.000Z' }) + } + const first = harness.engine.search({ query: 'needle', limit: 3 }) + const second = harness.engine.search({ query: 'needle', limit: 3, cursor: first.page.cursor! }) + const seen = [...first.hits, ...second.hits].map((hit) => hit.sessionId) + expect(seen).toEqual(['1', '2', '3', '4', '5', '6']) + }) +}) + +describe('a cursor is refused rather than reinterpreted', () => { + it('rejects a cursor minted before the index moved', async () => { + const { engine, store } = await withSessions(25) + const first = engine.search({ query: 'needle', limit: 10 }) + // A proven deletion of a path this index really held hides a session, which + // is exactly the change a cursor must not be allowed to page across. + store.removeFile('/synthetic/1.jsonl') + + expect(() => engine.search({ query: 'needle', limit: 10, cursor: first.page.cursor! })).toThrow( + SessionSearchCursorError + ) + try { + engine.search({ query: 'needle', limit: 10, cursor: first.page.cursor! }) + expect.unreachable('a stale cursor must not be silently re-run') + } catch (error) { + expect((error as SessionSearchCursorError).rejection).toBe('stale-generation') + } + }) + + it('names both generations, so a caller can tell a moved index from a bad cursor', async () => { + // What a caller does about it differs: a moved index means quietly ask for + // page one again, a bad cursor means something is wrong with the caller. + const { engine, store } = await withSessions(25) + const first = engine.search({ query: 'needle', limit: 10 }) + const minted = readIndexGeneration(harness!.db) + // Any published read moves the generation, including one for a file this + // page never mentioned. That is the fence working, not a defect. + store.removeFile('/synthetic/9.jsonl') + + try { + engine.search({ query: 'needle', limit: 10, cursor: first.page.cursor! }) + expect.unreachable('the index moved') + } catch (error) { + const rejected = error as SessionSearchCursorError + expect(rejected.rejection).toBe('stale-generation') + expect(rejected.expectedGeneration).toBe(minted) + expect(rejected.actualGeneration).toBe(readIndexGeneration(harness!.db)) + expect(rejected.actualGeneration).toBeGreaterThan(rejected.expectedGeneration!) + } + }) + + it('rejects a cursor carried over to a different query', async () => { + const { engine } = await withSessions(25) + const first = engine.search({ query: 'needle', limit: 10 }) + try { + engine.search({ query: 'padding', limit: 10, cursor: first.page.cursor! }) + expect.unreachable('a cursor indexes into one ranked list, not any list') + } catch (error) { + expect((error as SessionSearchCursorError).rejection).toBe('different-query') + } + }) + + it('rejects a cursor whose filters changed, which reranks the list', async () => { + const { engine } = await withSessions(25) + const first = engine.search({ query: 'needle', limit: 10 }) + try { + engine.search({ + query: 'needle', + limit: 10, + cursor: first.page.cursor!, + filters: { sort: 'newest' } + }) + expect.unreachable('a different sort is a different ranked list') + } catch (error) { + expect((error as SessionSearchCursorError).rejection).toBe('different-query') + } + }) + + // Every field the ranked list depends on has to be in the key, and a field + // that is in the key but never pinned is a field a refactor can drop while + // the suite stays green. One case each, through the engine, so the assertion + // is about a refused page and not about a hash. + it.each([ + ['scope', { scope: 'conversation' as const }], + ['sort', { filters: { sort: 'newest' as const } }], + ['agents', { filters: { agents: ['codex' as const] } }], + ['scopePaths', { filters: { scopePaths: ['/repo/app'] } }], + ['since', { filters: { since: '2026-09-01T00:00:00.000Z' } }] + ])('rejects a cursor presented with a different %s', async (_field, changed) => { + const { engine } = await withSessions(25) + const request: SessionSearchRequest = { + query: 'needle', + limit: 10, + scope: 'all', + filters: { sort: 'relevance', agents: ['claude'], scopePaths: ['/'], since: undefined } + } + const first = engine.search(request) + expect(first.page.cursor).not.toBeNull() + try { + engine.search({ + ...request, + ...changed, + filters: { ...request.filters, ...('filters' in changed ? changed.filters : {}) }, + cursor: first.page.cursor! + }) + expect.unreachable('a narrowing the ranked list depends on must invalidate the cursor') + } catch (error) { + expect((error as SessionSearchCursorError).rejection).toBe('different-query') + } + }) + + it('rejects a cursor that is not one of ours', async () => { + const { engine } = await withSessions(3) + try { + engine.search({ query: 'needle', cursor: 'not-a-cursor' }) + expect.unreachable('a malformed cursor is not an empty one') + } catch (error) { + expect((error as SessionSearchCursorError).rejection).toBe('malformed') + } + }) +}) + +describe('cursor encoding', () => { + const request: SessionSearchRequest = { query: 'needle', filters: { scopePaths: ['/a'] } } + + it('round-trips an offset within its own generation and query', () => { + const key = sessionSearchPageKey(request) + expect(decodeSessionSearchCursor(encodeSessionSearchCursor(7, 40, key), 7, key)).toBe(40) + }) + + it('keys a request by what changes its ranking, and not by its page size', () => { + expect(sessionSearchPageKey({ ...request, limit: 5 })).toBe( + sessionSearchPageKey({ ...request, limit: 50 }) + ) + expect(sessionSearchPageKey({ ...request, scope: 'conversation' })).not.toBe( + sessionSearchPageKey(request) + ) + }) + + it('reads a filter list in any order as the same request', () => { + expect(sessionSearchPageKey({ query: 'a', filters: { agents: ['claude', 'codex'] } })).toBe( + sessionSearchPageKey({ query: 'a', filters: { agents: ['codex', 'claude'] } }) + ) + }) + + it.each([ + ['a negative offset', encodeSessionSearchCursor(1, -1, 'k'), 1], + ['a non-integer offset', Buffer.from('{"g":1,"o":1.5,"k":"k"}').toString('base64url'), 1], + ['a payload that is not an object', Buffer.from('"nope"').toString('base64url'), undefined], + ['text that is not base64url JSON', 'zzz!!', undefined], + // A generation is a counter: neither of these is a snapshot that ever + // existed, so reporting one as stale would name a generation as expected. + [ + 'a fractional generation', + Buffer.from('{"g":7.5,"o":0,"k":"k"}').toString('base64url'), + undefined + ], + [ + 'a negative generation', + Buffer.from('{"g":-1,"o":0,"k":"k"}').toString('base64url'), + undefined + ] + ])('rejects %s as malformed, still naming the index generation', (_name, cursor, claimed) => { + // The caller has to know which snapshot it was refused against whatever was + // wrong with the cursor, and the generation it claimed whenever that + // survived parsing. + try { + decodeSessionSearchCursor(cursor, 7, 'k') + expect.unreachable('a malformed cursor is not an empty one') + } catch (error) { + const rejected = error as SessionSearchCursorError + expect(rejected.rejection).toBe('malformed') + expect(rejected.actualGeneration).toBe(7) + expect(rejected.expectedGeneration).toBe(claimed) + } + }) +}) + +describe('the candidate limit is a tunable default, and says when it cut', () => { + it('does not claim truncation when every session fits', async () => { + const { engine } = await withSessions(5, { sessionCandidateLimit: 600 }) + expect(engine.search({ query: 'needle' }).truncated.candidates).toBe(false) + }) + + it('claims truncation, and ranks only what it retrieved, at the limit', async () => { + const { engine } = await withSessions(10, { sessionCandidateLimit: 4 }) + const result = engine.search({ query: 'needle', limit: 100 }) + expect(result.truncated.candidates).toBe(true) + expect(result.hits).toHaveLength(4) + }) + + it('applies the same limit to an operator-only page', async () => { + const { engine } = await withSessions(10, { sessionCandidateLimit: 4 }) + const result = engine.search({ query: 'repo:app', limit: 100 }) + expect(result.truncated.candidates).toBe(true) + expect(result.hits).toHaveLength(4) + }) + + it('says it gave up when the operator walk stopped scanning, not that it is done', async () => { + // The shape that reads as a confident empty answer: the only match sits + // past the walk's ceiling, so the walk stops having found nothing. Zero + // hits and `truncated.candidates` false would tell a caller there is + // nothing to find, which is a different claim from "I stopped looking". + // The walk reads a page at a time and gives up past a ceiling of + // `candidateLimit` x 20, so the corpus has to be deeper than one page for + // the ceiling to be what ends it. The only match is the oldest session. + const deep = 600 + const { db, engine } = await open('ss-engine-sparse-deep', { sessionCandidateLimit: 2 }) + for (let id = 1; id <= deep; id++) { + addSyntheticSession(db, { + id, + cwd: id === deep ? '/repo/needleonly' : '/repo/app', + updatedAt: new Date(Date.UTC(2026, 8, 9) - id * 60_000).toISOString() + }) + } + const result = engine.search({ query: 'repo:needleonly' }) + expect(result.hits).toHaveLength(0) + expect(result.truncated.candidates).toBe(true) + }) + + it('does not claim it gave up when the walk really did read everything', async () => { + const { db, engine } = await open('ss-engine-sparse-shallow', { sessionCandidateLimit: 600 }) + addSyntheticSession(db, { id: 1, cwd: '/repo/app' }) + const result = engine.search({ query: 'repo:nothing-here' }) + expect(result.hits).toHaveLength(0) + expect(result.truncated.candidates).toBe(false) + }) +}) + +describe('the response carries the snapshot it was built from', () => { + it('reports the index generation on every result', async () => { + const { db, engine, store } = await withSessions(3) + const before = engine.search({ query: 'needle' }).generation + expect(before).toBe(readIndexGeneration(db)) + store.removeFile('/synthetic/1.jsonl') + const after = engine.search({ query: 'needle' }).generation + expect(after).toBe(readIndexGeneration(db)) + expect(after).toBeGreaterThan(before) + }) +}) + +it.each([false, true])('rejects a write during page assembly (cursor: %s)', async (withCursor) => { + const { db, engine, store } = await open('ss-concurrent-page') + for (let id = 1; id <= 3; id++) { + addSyntheticSession(db, { id, text: 'needle' }) + } + const first = engine.search({ query: 'needle', limit: 1 }) + const prepare = db.prepare.bind(db) + let committed = false + const hook = vi.spyOn(db, 'prepare').mockImplementation((sql) => { + if (!committed && sql.includes('SELECT DISTINCT session_row_id FROM files')) { + committed = true + store.removeFile('/synthetic/1.jsonl') + } + return prepare(sql) + }) + try { + expect(() => + engine.search({ + query: 'needle', + limit: 1, + ...(withCursor ? { cursor: first.page.cursor! } : {}) + }) + ).toThrow(SessionSearchCursorError) + expect(committed).toBe(true) + expect(readIndexGeneration(db)).toBeGreaterThan(first.generation) + } finally { + hook.mockRestore() + } +}) diff --git a/src/main/ai-vault-search/session-search-query-planner.test.ts b/src/main/ai-vault-search/session-search-query-planner.test.ts new file mode 100644 index 00000000000..ac4874b1d10 --- /dev/null +++ b/src/main/ai-vault-search/session-search-query-planner.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { + andExpression, + isLiteralQuery, + orExpression, + phraseExpression, + planSessionSearchQuery, + quoteFtsTerm +} from './session-search-query-planner' + +describe('literal shape decides whether the phrase route is even tried', () => { + it.each([ + 'resolveTerminalPath', + 'src/main/foo-bar.ts', + 'MAX_RETRY_COUNT', + 'kern.tty.ptmx_max', + '#19687', + 'STA-4850', + '"exact words here"', + 'TypeError: undefined', + 'foo() {' + ])('treats %s as quoting something from a transcript', (query) => { + expect(isLiteralQuery(query)).toBe(true) + }) + + it.each(['why is the terminal slow', 'how do I resume a session', 'relay capacity'])( + 'treats %s as prose', + (query) => { + expect(isLiteralQuery(query)).toBe(false) + } + ) +}) + +describe('the body is what the phrase and AND routes see', () => { + it('drops stop words from prose so the AND route is not defeated by "the"', () => { + expect(planSessionSearchQuery('why is the relay dropping frames').body).toEqual([ + 'relay', + 'dropping', + 'frames' + ]) + }) + + it('keeps stop words inside a literal, where they are part of what was quoted', () => { + // The literal shape is `foo.ts`; dropping `the` would change what was typed. + expect(planSessionSearchQuery('the foo.ts file').body).toEqual(['the', 'foo.ts', 'file']) + }) + + it('keeps a query that is nothing but stop words rather than answering nothing', () => { + expect(planSessionSearchQuery('how do I').body).toEqual(['how', 'do', 'I']) + }) + + it('has no terms for a query with no searchable token', () => { + expect(planSessionSearchQuery(' ... ').terms).toEqual([]) + }) +}) + +describe('the OR fallback fans an identifier out into its pieces', () => { + it('adds the split pieces after the whole term, never in place of it', () => { + const plan = planSessionSearchQuery('resolveTerminalPath') + expect(plan.terms[0]).toBe('resolveTerminalPath') + expect(plan.terms).toContain('terminal') + expect(plan.terms).toContain('path') + // `resolve` is not a stop word, so the whole identifier is reachable by piece. + expect(plan.terms).toContain('resolve') + }) + + it('leaves an ordinary word alone', () => { + expect(planSessionSearchQuery('relay').terms).toEqual(['relay']) + }) +}) + +describe('FTS5 expressions quote every term', () => { + it('quotes punctuation that would otherwise be syntax', () => { + expect(quoteFtsTerm('cli.mjs')).toBe('"cli.mjs"') + expect(quoteFtsTerm('C++')).toBe('"C++"') + expect(quoteFtsTerm('say "hi"')).toBe('"say ""hi"""') + }) + + it('builds one phrase, an AND chain, and an OR chain from the same terms', () => { + expect(phraseExpression(['alpha', 'beta'])).toBe('"alpha beta"') + expect(andExpression(['alpha', 'beta'])).toBe('"alpha" AND "beta"') + expect(orExpression(['alpha', 'beta'])).toBe('"alpha" OR "beta"') + }) +}) diff --git a/src/main/ai-vault-search/session-search-query-planner.ts b/src/main/ai-vault-search/session-search-query-planner.ts new file mode 100644 index 00000000000..6c2c2f3b91c --- /dev/null +++ b/src/main/ai-vault-search/session-search-query-planner.ts @@ -0,0 +1,140 @@ +import type { SessionSearchScope } from './session-search-engine-types' +import { identifierShadowTerms } from './session-search-identifier-split' + +// Tokens exactly as the unicode61 tokenizer with `_ . - / +` tokenchars emits them. +const INDEX_TOKEN = /[\p{L}\p{N}\p{M}\p{Co}_./+-]+/gu +const STOP_WORDS = new Set( + ( + 'a an and are as at be but by for from how i if in into is it its of on or that the this to ' + + 'was were what when where which who why with you your we my me do does did not no can could ' + + 'should would about our us they them there their has have had been being so such then than ' + + "these those there's im ive dont" + ).split(' ') +) +const MAX_BODY_TERMS = 48 +const MAX_TERMS = 64 + +// A query that quotes something from a transcript: camelCase, SCREAMING_SNAKE, +// a dotted or snake_case name, a path, a filename, a PR number, a ticket, code +// punctuation, or an error word. +const LITERAL_SHAPE = + /[A-Za-z0-9_]*[a-z][A-Z][A-Za-z0-9_]*|\b[A-Z][A-Z0-9]{2,}(_[A-Z0-9]+)+\b|\b\w{2,}[._]\w{2,}\b|\b[\w.-]+\/[\w/.-]+\b|\b\w+\.(ts|tsx|js|jsx|py|rs|go|json|md|sh|yml|yaml|toml|c|cc|h|java|sql)\b|#\d{3,}|\b[A-Z]{2,6}-\d{2,}\b|[(){};=]|::|->|--\w|\b(Error|Exception|Traceback|error:|warning:)\b/ +const QUOTED = /"[^"]{3,}"|'[^']{3,}'/ + +export type SessionSearchQueryPlan = { + literal: boolean + /** + * The query had more terms than the planner will search. What is dropped is + * the tail, so a match that only the last term would have found is missed; + * the caller is told rather than handed a confident empty answer. + */ + truncated: boolean + /** Deduplicated index-faithful terms for the OR fallback, incl. identifier pieces. */ + terms: string[] + /** Query-order tokens minus stop words: the phrase / AND candidate. */ + body: string[] +} + +export function isLiteralQuery(query: string): boolean { + return QUOTED.test(query) || LITERAL_SHAPE.test(query) +} + +/** + * The tokenizer contract, unfolded: the same boundaries FTS5 draws for + * `unicode61 tokenchars '_.-/+'`. Pinned against real `fts5vocab` output in + * session-search-fts5-contract.test.ts, which is what makes it safe to plan a + * query without asking SQLite. + */ +export function indexTokens(query: string, limit = Number.POSITIVE_INFINITY): string[] { + const out: string[] = [] + for (const match of query.matchAll(INDEX_TOKEN)) { + const token = match[0] + // Separators alone (`--`, `...`) are a token to FTS5 but never a search term. + if (/[\p{L}\p{N}\p{Co}]/u.test(token)) { + out.push(token) + if (out.length >= limit) { + break + } + } + } + return out +} + +/** + * `literal` overrides the shape test. Typo repair re-plans the query it + * corrected, and a corrected spelling can look like ordinary prose even though + * what was typed was a literal: `parseJsonn(the, data)` has the punctuation that + * makes it literal, `parsejson the data` does not. Without the override the + * re-plan would drop `the` as a stop word, so the repaired query would search + * for less than the original asked for and `repairedTerms` would report a body + * the user never typed. + */ +export function planSessionSearchQuery( + query: string, + literal = isLiteralQuery(query) +): SessionSearchQueryPlan { + // One past the cap, so the plan can tell a query that just fits from one that + // was cut. `indexTokens` stops at its limit, so it cannot be asked afterwards. + const overCap = indexTokens(query, MAX_BODY_TERMS + 1) + const truncated = overCap.length > MAX_BODY_TERMS + const raw = overCap.slice(0, MAX_BODY_TERMS) + let body = literal ? raw : raw.filter((token) => !STOP_WORDS.has(token.toLowerCase())) + if (body.length < 2) { + body = raw + } + const terms = [...new Set(body)] + const extra: string[] = [] + for (const term of terms) { + for (const piece of identifierShadowTerms(term, 12)) { + if (!terms.includes(piece) && !STOP_WORDS.has(piece) && !extra.includes(piece)) { + extra.push(piece) + } + } + } + return { + literal, + truncated, + terms: [...terms, ...extra].slice(0, MAX_TERMS), + body: body.slice(0, MAX_BODY_TERMS) + } +} + +// Why: `cli.mjs`, `foo-bar`, and `C++` are all FTS5 syntax errors unquoted. +export function quoteFtsTerm(term: string): string { + return `"${term.replaceAll('"', '""')}"` +} + +export function phraseExpression(terms: readonly string[]): string { + return quoteFtsTerm(terms.join(' ')) +} + +export function andExpression(terms: readonly string[]): string { + return terms.map(quoteFtsTerm).join(' AND ') +} + +export function orExpression(terms: readonly string[]): string { + return terms.map(quoteFtsTerm).join(' OR ') +} + +/** + * What a scope is, now that there is one FTS table. + * + * `conversation` used to be a second table holding a copy of the two prose + * columns. It is a column filter instead: PR 2 measured the filter at + * 1.16-1.36x the p95 of the dedicated table on a 105 MB corpus, against a 2x + * bar, and the table cost a tenth of the index to maintain. + * + * It lives beside the other expression builders, and not with the retrieval + * that uses it, because the typo repair has to ask the same question of the + * same scope and importing it from there is a cycle. + * + * The filter binds to the whole expression, so it is applied here and nowhere + * else — `{cols}: (a AND b)` filters both terms, while a prefix pasted in front + * of a bare `a AND b` would filter only `a` and quietly search tool output for + * the rest. + */ +const CONVERSATION_COLUMNS = '{user_text assistant_text}' + +export function scopedExpression(scope: SessionSearchScope, expression: string): string { + return scope === 'all' ? expression : `${CONVERSATION_COLUMNS}: (${expression})` +} diff --git a/src/main/ai-vault-search/session-search-query-schema.ts b/src/main/ai-vault-search/session-search-query-schema.ts new file mode 100644 index 00000000000..f01c2d42d18 --- /dev/null +++ b/src/main/ai-vault-search/session-search-query-schema.ts @@ -0,0 +1,42 @@ +import type SyncDatabase from '../sqlite/sync-database' +import { + SESSION_SEARCH_GENERATION_SQL, + SESSION_SEARCH_GENERATION_TRIGGERS +} from './session-search-index-generation' + +const QUERY_SCHEMA_SQL = ` +-- The typo repair's whole dictionary. Why the index's own vocabulary and not a +-- word list: it can never suggest a term this index does not hold, and it needs +-- no model. fts5vocab is a view over the FTS5 b-tree, so it costs no extra rows. +CREATE VIRTUAL TABLE IF NOT EXISTS messages_vocab USING fts5vocab(messages_fts, 'row'); +${SESSION_SEARCH_GENERATION_SQL}` + +/** Everything the SQL above creates, so a missing one is what triggers a re-run. */ +const OWNED = ['messages_vocab', ...SESSION_SEARCH_GENERATION_TRIGGERS] + +/** + * The vocabulary's target. Creating a fts5vocab table over a missing FTS table + * succeeds and every query against it then fails, so the feature's health is + * this name's presence rather than the vocabulary's own. + */ +const VOCABULARY_SOURCE = 'messages_fts' + +const PROBED = [...OWNED, VOCABULARY_SOURCE] + +/** Restore derived objects; a missing source index requires the owner to rebuild. */ +export function ensureSessionSearchQuerySchema(db: SyncDatabase): void { + const present = presentNames(db) + if (!present.has(VOCABULARY_SOURCE)) { + throw new Error('Session search index unavailable: missing messages_fts') + } + if (OWNED.some((name) => !present.has(name))) { + db.exec(QUERY_SCHEMA_SQL) + } +} + +function presentNames(db: SyncDatabase): Set { + const rows = db + .prepare(`SELECT name FROM sqlite_master WHERE name IN (${PROBED.map(() => '?').join(',')})`) + .all(...PROBED) as { name: string }[] + return new Set(rows.map((row) => row.name)) +} diff --git a/src/main/ai-vault-search/session-search-retrieval.ts b/src/main/ai-vault-search/session-search-retrieval.ts new file mode 100644 index 00000000000..59fef23e903 --- /dev/null +++ b/src/main/ai-vault-search/session-search-retrieval.ts @@ -0,0 +1,244 @@ +import type SyncDatabase from '../sqlite/sync-database' +import type { SessionSearchRoute, SessionSearchScope } from './session-search-engine-types' +import type { MessageRow, SessionRow } from './session-search-hit-ranking' +import { + andExpression, + orExpression, + phraseExpression, + planSessionSearchQuery, + scopedExpression, + type SessionSearchQueryPlan +} from './session-search-query-planner' +import type { SessionRowFilter } from './session-search-row-filter' +import { SessionSearchTypoRepair } from './session-search-typo-repair' + +// The operator-only walk: rows per page, and how far past a full candidate set +// it will read before giving up on finding more matches. +const RECENT_PAGE_ROWS = 512 +// Ids per `loadSessions` statement, with room to spare for the filter's own +// bound values beside them. +const SESSION_ID_BATCH = 500 +const RECENT_SCAN_FACTOR = 20 + +// Measured: user 3 / assistant 2 / tool 1 / identifiers 1 (MRR 0.503 vs 0.475 flat). +const FULL_WEIGHTS = '3.0, 2.0, 1.0, 1.0' +// Tool and identifier columns do not contribute to conversation ranking. +const CONVERSATION_WEIGHTS = '3.0, 2.0, 0.0, 0.0' + +export type RetrievalScope = { + scope: SessionSearchScope + sort: 'relevance' | 'newest' + filter: SessionRowFilter + /** + * `repo:` / `path:`, which SQL cannot express. Applied over retrieved rows; + * see session-search-row-filter for why it cannot be pushed down. + */ + matchesOperators: (session: SessionRow) => boolean + /** + * Sessions retrieved before ranking cuts the page. See + * docs/reference/agent-session-search-query-tuning.md for the measurements + * behind the default; it is an option because the right value depends on how + * large an index is and no single number is right for every host. + */ + candidateLimit: number +} + +export type Retrieved = { + sessions: SessionRow[] + rows: MessageRow[] + incomplete: boolean + route: SessionSearchRoute + /** The plan the rows were actually retrieved by; snippets highlight from it. */ + plan: SessionSearchQueryPlan + repairedTerms?: string[] +} + +/** + * The bm25 weights a scope ranks with. The conversation pair stays here rather + * than beside `scopedExpression`, because weights are a property of this SQL + * and nothing else asks for them. + */ +export function scopedWeights(scope: SessionSearchScope): string { + return scope === 'all' ? FULL_WEIGHTS : CONVERSATION_WEIGHTS +} + +/** The FTS half of a search: the route ladder and the SQL each rung runs. */ +export class SessionSearchRetrieval { + private readonly typoRepair: SessionSearchTypoRepair + + constructor(private readonly db: SyncDatabase) { + this.typoRepair = new SessionSearchTypoRepair(db) + } + + /** + * The route ladder: phrase, then AND for a literal-looking query, then typo + * repair, then OR. + * + * Repair runs before the OR fallback rather than after it fails. A typo next + * to a common word would otherwise be masked: the common word alone retrieves + * plenty of rows over OR, so nothing would ever look like a miss worth + * repairing. + */ + run(plan: SessionSearchQueryPlan, scope: RetrievalScope): Retrieved { + let incomplete = false + let sessions: SessionRow[] = [] + const match = (expression: string): MessageRow[] => { + const rows = this.match(expression, scope) + incomplete ||= rows.length >= scope.candidateLimit + sessions = this.loadSessions( + rows.map((row) => row.session_row_id), + scope + ) + const eligible = new Set(sessions.map((row) => row.id)) + return rows.filter((row) => eligible.has(row.session_row_id)) + } + const exact = this.literal(plan, match) + if (exact) { + return { ...exact, plan, incomplete, sessions } + } + const repaired = this.repair(plan, scope.scope) + const effective = repaired ?? plan + const literal = repaired ? this.literal(repaired, match) : null + const found = literal ?? { + rows: match(orExpression(effective.terms)), + route: 'or' as const + } + return { + sessions, + rows: found.rows, + incomplete, + route: repaired ? (`typo+${found.route}` as SessionSearchRoute) : found.route, + plan: effective, + ...(repaired ? { repairedTerms: repaired.body } : {}) + } + } + + /** + * Newest sessions the constraints allow: what an operator-only query names. + * + * Walked in pages rather than taken in one `LIMIT`, because the operators are + * applied in JS. A single cut of the newest N would hand ranking whatever + * happened to be recent and then throw most of it away, so `repo:x` on a busy + * index could answer with nothing while plenty matched. The walk is bounded + * both ways: it stops at a full candidate set, and at a ceiling on rows read. + */ + recent(scope: RetrievalScope): { sessions: SessionRow[]; incomplete: boolean } { + const { conditions, values } = scope.filter + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' + const page = this.db.prepare( + `SELECT * FROM sessions ${where} + ORDER BY updated_at DESC, id DESC LIMIT ? OFFSET ?` + ) + const ceiling = scope.candidateLimit * RECENT_SCAN_FACTOR + const sessions: SessionRow[] = [] + let scanned = 0 + // Why the flag and not a count: both caps mean the same thing to a caller — + // a session it never saw may have matched — and only the loop knows which + // of them ended it. Reporting rows read instead let the engine infer + // completeness from a full candidate set alone, so giving up at the ceiling + // with nothing found looked exactly like a search that found nothing. + let incomplete = false + while (sessions.length < scope.candidateLimit) { + if (scanned >= ceiling) { + incomplete = true + break + } + const rows = page.all(...values, RECENT_PAGE_ROWS, scanned) as SessionRow[] + if (rows.length === 0) { + break + } + scanned += rows.length + for (const row of rows) { + if (sessions.length < scope.candidateLimit && scope.matchesOperators(row)) { + sessions.push(row) + } + } + } + return { sessions, incomplete: incomplete || sessions.length >= scope.candidateLimit } + } + + /** Bound SQL parameters independently of the configurable candidate limit. */ + private loadSessions(ids: readonly number[], scope: RetrievalScope): SessionRow[] { + const rows: SessionRow[] = [] + for (let start = 0; start < ids.length; start += SESSION_ID_BATCH) { + const batch = ids.slice(start, start + SESSION_ID_BATCH) + const conditions = [`id IN (${batch.map(() => '?').join(',')})`, ...scope.filter.conditions] + rows.push( + ...(this.db + .prepare(`SELECT * FROM sessions WHERE ${conditions.join(' AND ')}`) + .all(...batch, ...scope.filter.values) as SessionRow[]) + ) + } + return rows.filter((row) => scope.matchesOperators(row)) + } + + private repair( + plan: SessionSearchQueryPlan, + scope: SessionSearchScope + ): SessionSearchQueryPlan | null { + const typoRepair = this.typoRepair + let changed = false + const body = plan.body.map((term) => { + // Repaired inside the scope the search will run in, so a spelling only + // tool output carries neither suppresses a repair nor becomes one. + const fix = typoRepair.correct(term, scope) + if (fix && fix !== term.toLowerCase()) { + changed = true + return fix + } + return term + }) + // The repair changes spellings, not the query's character: the re-plan is + // told what the original decided so a corrected literal keeps every term it + // was typed with. + return changed ? planSessionSearchQuery(body.join(' '), plan.literal) : null + } + + /** Phrase, then AND, for literal-looking queries; null when neither matches. */ + private literal( + plan: SessionSearchQueryPlan, + match: (expression: string) => MessageRow[] + ): { rows: MessageRow[]; route: 'phrase' | 'and' } | null { + if (!plan.literal || plan.body.length === 0) { + return null + } + // A one-token literal (`resolveTerminalPath`, `src/a/b.ts`) is its own + // phrase: the tokenizer keeps it whole, so the exact token is the cheap, + // precise first try before the identifier pieces fan out over OR. + const phrase = match(phraseExpression(plan.body)) + if (phrase.length > 0) { + return { rows: phrase, route: 'phrase' } + } + if (plan.body.length < 2) { + return null + } + const and = match(andExpression(plan.body)) + return and.length > 0 ? { rows: and, route: 'and' } : null + } + + private match(expression: string, scope: RetrievalScope): MessageRow[] { + const { filter, sort, candidateLimit } = scope + const eligible = filter.conditions.length + ? ` AND m.session_row_id IN (SELECT id FROM sessions WHERE ${filter.conditions.join(' AND ')})` + : '' + const matched = `SELECT messages_fts.rowid AS rowid, + -bm25(messages_fts, ${scopedWeights(scope.scope)}) AS score, + m.session_row_id, m.role, m.ts, s.updated_at + FROM messages_fts JOIN messages m ON m.id = messages_fts.rowid + JOIN sessions s ON s.id = m.session_row_id WHERE messages_fts MATCH ?${eligible}` + // Why: collapse to one row per session BEFORE the candidate limit, on both + // sort orders, so a single long session cannot occupy the whole page. + // `max(score)` makes SQLite pick that session's best row for the bare columns. + // Cost of grouping instead of a bounded top-N sorter, measured: ~1.75x + // (49.6 vs 28.6 ms at 80k matching rows, 183.6 vs 104.1 ms at 240k) and a + // temp b-tree over every match. No inner LIMIT can bound it: the CTE has no + // order, so any cut drops whole sessions rather than their surplus rows. + const order = sort === 'newest' ? 'updated_at DESC, score DESC' : 'score DESC' + const sql = `WITH matched AS MATERIALIZED (${matched}) + SELECT rowid, max(score) AS score, session_row_id, role, ts FROM matched + GROUP BY session_row_id ORDER BY ${order} LIMIT ${candidateLimit}` + return this.db + .prepare(sql) + .all(scopedExpression(scope.scope, expression), ...filter.values) as MessageRow[] + } +} diff --git a/src/main/ai-vault-search/session-search-row-filter.test.ts b/src/main/ai-vault-search/session-search-row-filter.test.ts new file mode 100644 index 00000000000..1cec0a528f9 --- /dev/null +++ b/src/main/ai-vault-search/session-search-row-filter.test.ts @@ -0,0 +1,147 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type SyncDatabase from '../sqlite/sync-database' +import type { SessionSearchFilters } from './session-search-engine-types' +import { cwdKey } from './session-search-file-records' +import { sessionRowFilter } from './session-search-row-filter' +import { + openSessionSearchIndexFile, + type SessionSearchIndexFile +} from './session-search-index-test-fixture' + +let index: SessionSearchIndexFile | null = null + +afterEach(async () => { + await index?.close() + index = null +}) + +async function openIndex(): Promise { + index = await openSessionSearchIndexFile('ss-row-filter') + return index.db +} + +function addSession( + db: SyncDatabase, + id: number, + cwd: string | null, + overrides: { agent?: string; updatedAt?: string } = {} +): void { + db.prepare( + `INSERT INTO sessions(id,agent,session_id,file_path,title,cwd,cwd_key,updated_at,resume_command) + VALUES (?,?,?,?,'fixture',?,?,?,'')` + ).run( + id, + overrides.agent ?? 'claude', + String(id), + `/synthetic/${id}`, + cwd, + cwdKey(cwd), + overrides.updatedAt ?? '2026-09-01T00:00:00.000Z' + ) +} + +function selected(db: SyncDatabase, filters: SessionSearchFilters = {}): number[] { + const filter = sessionRowFilter(filters) + const where = filter.conditions.length > 0 ? `WHERE ${filter.conditions.join(' AND ')}` : '' + return ( + db.prepare(`SELECT id FROM sessions ${where} ORDER BY id`).all(...filter.values) as { + id: number + }[] + ).map((row) => row.id) +} + +describe('a cwd scope is the sidebar key, or anything below it', () => { + it.each([ + ['C:\\Work\\App', 'c:/work/app', true], + ['C:\\Work\\App\\src', 'c:/work/app', true], + ['/work/APP/src', '/work/app', false], + ['/work/caf\u00e9', '/work/cafe\u0301', true], + ['/work/app-other', '/work/app', false], + ['/work/a_b/src', '/work/a_b', true], + ['/work/axb/src', '/work/a_b', false], + // Roots: `/` is the one key that is already a separator, which is where a + // range bound is easiest to get wrong. A Windows key is not under POSIX `/`. + ['/', '/', true], + ['/work/app', '/', true], + ['C:\\Work\\App', '/', false], + ['C:\\', 'C:\\', true], + ['C:\\Work\\App', 'C:\\', true] + ])('scopes %s under %s: %s', async (cwd, scope, expected) => { + const db = await openIndex() + addSession(db, 1, cwd) + expect(selected(db, { scopePaths: [scope] })).toEqual(expected ? [1] : []) + }) + + it('never matches a session whose transcript recorded no cwd', async () => { + const db = await openIndex() + addSession(db, 1, null) + expect(selected(db, { scopePaths: ['/work'] })).toEqual([]) + expect(selected(db)).toEqual([1]) + }) + + it('narrows to nothing when no scope the caller gave could be keyed', async () => { + // `cwdKey` returns null for a scope it cannot key, and a scope that matches + // nothing must return nothing; dropping it would answer the whole index. + const db = await openIndex() + addSession(db, 1, '/work/app') + addSession(db, 2, '/elsewhere') + expect(selected(db, { scopePaths: [''] })).toEqual([]) + expect(selected(db, { scopePaths: ['', '/work/app'] })).toEqual([1]) + }) + + it('keeps a WSL UNC workspace distinct from the bare Linux spelling', async () => { + // PR 2 decided cwd_key does not qualify a Linux path with its distro: the + // collision is real but every SSH host has it too, and the fix is a column + // naming the execution host, not a key only some hosts spell differently. + const db = await openIndex() + addSession(db, 1, '\\\\wsl.localhost\\Ubuntu\\home\\ada\\app') + addSession(db, 2, '/home/ada/app') + expect(selected(db, { scopePaths: ['\\\\wsl$\\Ubuntu\\home\\ada'] })).toEqual([1]) + expect(selected(db, { scopePaths: ['/home/ada/app'] })).toEqual([2]) + expect(selected(db, { scopePaths: ['\\\\wsl$\\Debian\\home\\ada\\app'] })).toEqual([]) + }) +}) + +describe('caller filters', () => { + it('narrows by agent, and by updated-at floor', async () => { + const db = await openIndex() + addSession(db, 1, '/work/app', { agent: 'claude', updatedAt: '2026-09-01T00:00:00.000Z' }) + addSession(db, 2, '/work/app', { agent: 'codex', updatedAt: '2026-09-05T00:00:00.000Z' }) + expect(selected(db, { agents: ['codex'] })).toEqual([2]) + expect(selected(db, { since: '2026-09-03T00:00:00.000Z' })).toEqual([2]) + expect(selected(db, { agents: ['claude'], since: '2026-09-03T00:00:00.000Z' })).toEqual([]) + }) + + it('applies the retention cutoff through the files table', async () => { + const db = await openIndex() + addSession(db, 1, '/work/app') + addSession(db, 2, '/work/app') + db.prepare( + "INSERT INTO files(path,byte_offset,mtime_ms,session_row_id) VALUES ('a',0,100,1)" + ).run() + db.prepare( + "INSERT INTO files(path,byte_offset,mtime_ms,session_row_id) VALUES ('b',0,500,2)" + ).run() + const filter = sessionRowFilter({}, 300) + const rows = db + .prepare(`SELECT id FROM sessions WHERE ${filter.conditions.join(' AND ')}`) + .all(...filter.values) as { id: number }[] + expect(rows.map((row) => row.id)).toEqual([2]) + }) +}) + +it('plans a cwd scope as a seek on sessions_cwd_key, never a scan', async () => { + const db = await openIndex() + const filter = sessionRowFilter({ scopePaths: ['/work/app'] }) + const plan = ( + db + .prepare( + `EXPLAIN QUERY PLAN SELECT id FROM sessions WHERE ${filter.conditions.join(' AND ')}` + ) + .all(...filter.values) as { detail: string }[] + ).map((row) => row.detail) + + expect(plan.join(' | ')).toContain('sessions_cwd_key') + expect(plan.some((detail) => detail.startsWith('SEARCH'))).toBe(true) + expect(plan.some((detail) => detail.startsWith('SCAN sessions'))).toBe(false) +}) diff --git a/src/main/ai-vault-search/session-search-row-filter.ts b/src/main/ai-vault-search/session-search-row-filter.ts new file mode 100644 index 00000000000..4f5b6106e7d --- /dev/null +++ b/src/main/ai-vault-search/session-search-row-filter.ts @@ -0,0 +1,90 @@ +import { cwdKey } from './session-search-file-records' +import type { SessionSearchFilters } from './session-search-engine-types' + +/** SQL fragments for the `sessions` WHERE clause; every condition is ANDed. */ +export type SessionRowFilter = { + conditions: string[] + values: (string | number)[] +} + +// Stored identity: `cwdKey` is the sidebar's `folderGroupKey` without its prefix, +// so a scope term and an indexed session are keyed by one function, never two. +const CWD = 'cwd_key' + +/** + * The narrowings SQL can express exactly, in one place, so retrieval, the + * operator-only page and the session load cannot drift apart. These conditions + * run over `sessions` itself. Reachability is not here and is not a condition: + * it is the INNER JOIN to `sessions` that every retrieval carries, which is + * what makes a message row a purge has not reclaimed yet unreadable. + * + * `repo:` and `path:` are deliberately absent. What they mean is the predicate + * the sessions panel applies (`matchesAiVaultQueryOperators`), and SQL cannot + * express it: LIKE folds ASCII and nothing else, so `path:CAFÉ` would miss + * `café`; `path:` searches the transcript path as well as the working + * directory, so `path:jsonl` would miss every session; and `repo:` compares the + * last two path segments, not one. A second spelling that came close would be a + * query meaning different things in the list and in the index, so the engine + * applies the panel's own predicate over the rows it retrieves instead. + * + * `scopePaths` stays here because it is exact: a prefix range over the key + * `cwdKey` produces, which folds exactly where the execution host folds — + * Windows drives, never a POSIX directory name. + */ +export function sessionRowFilter( + filters: SessionSearchFilters, + cutoffMs: number | null = null +): SessionRowFilter { + const filter: SessionRowFilter = { conditions: [], values: [] } + if (cutoffMs !== null) { + filter.conditions.push('id IN (SELECT session_row_id FROM files WHERE mtime_ms >= ?)') + filter.values.push(cutoffMs) + } + if (filters.agents && filters.agents.length > 0) { + filter.conditions.push(`agent IN (${filters.agents.map(() => '?').join(',')})`) + filter.values.push(...filters.agents) + } + if (filters.since) { + filter.conditions.push('updated_at >= ?') + filter.values.push(filters.since) + } + if (filters.scopePaths && filters.scopePaths.length > 0) { + // Several scopes mean any of them; every other narrowing is ANDed on. + const present = filters.scopePaths + .map((scope) => scopeCondition(filter, scope)) + .filter((condition) => condition !== null) + // Every scope unkeyable still means a scope, so it narrows to nothing; + // pushing no condition would widen the search to every session instead. + filter.conditions.push(present.length > 0 ? `(${present.join(' OR ')})` : '0 = 1') + } + return filter +} + +/** A scope the caller could not key is a scope nothing is inside of. */ +function scopeCondition(filter: SessionRowFilter, scope: string): string | null { + const key = cwdKey(scope) + return key === null ? null : insideCondition(filter, key) +} + +/** + * `key` itself, or anything below it. Why a half-open range and not + * `substr(key, 1, length(?)) = ?`: only `>=`/`<` can seek `sessions_cwd_key`; + * the substr form scans it. The bound is the child prefix with its last byte + * incremented, so it stops at the end of that prefix and nowhere else. The two + * arms cannot merge: one range over the bare key would also swallow a sibling + * like `/work/app-other`. No wildcards, so `%`/`_` in a folder name are literal. + * + * The filesystem root is the one key that already ends in a separator, and + * appending a second one would bound the range at `//`, which sorts below every + * real child; `cwdKey` keeps it as `/` for exactly this reason. + */ +function insideCondition(filter: SessionRowFilter, key: string): string { + const children = key.endsWith('/') ? key : `${key}/` + filter.values.push(key, children, nextAfterPrefix(children)) + return `(${CWD} = ? OR (${CWD} >= ? AND ${CWD} < ?))` +} + +/** The first string that sorts after every string starting with `prefix`. */ +function nextAfterPrefix(prefix: string): string { + return prefix.slice(0, -1) + String.fromCharCode(prefix.charCodeAt(prefix.length - 1) + 1) +} diff --git a/src/main/ai-vault-search/session-search-sidebar-parity.test.ts b/src/main/ai-vault-search/session-search-sidebar-parity.test.ts new file mode 100644 index 00000000000..988ae248673 --- /dev/null +++ b/src/main/ai-vault-search/session-search-sidebar-parity.test.ts @@ -0,0 +1,146 @@ +import { afterEach, expect, it } from 'vitest' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { filterAiVaultSessions } from '../../shared/ai-vault-session-filters' +import { + addSyntheticSession, + openSessionSearchHarness, + type SessionSearchHarness +} from './session-search-engine-test-fixture' + +// `repo:` and `path:` have to mean one thing. The sessions panel and the index +// answer from different stores by different mechanisms, so the only way to keep +// them equal is for both to run the same predicate; this asserts they do, over +// the shapes where a second SQL spelling went wrong. + +let harness: SessionSearchHarness | null = null + +afterEach(async () => { + await harness?.close() + harness = null +}) + +type Fixture = { id: number; cwd: string; filePath: string; text: string } + +const SESSIONS: Fixture[] = [ + { + id: 1, + cwd: '/Users/Ada/orca/session-search', + filePath: '/Users/Ada/.claude/projects/a/one.jsonl', + text: 'harbor pilot manifest' + }, + { + id: 2, + cwd: '/Users/ada/work/café', + filePath: '/Users/ada/.codex/sessions/two.jsonl', + text: 'harbor dock crane' + }, + { + id: 3, + cwd: '/srv/other/service', + filePath: '/srv/.claude/projects/b/three.jsonl', + text: 'harbor manifest beta' + }, + { + id: 4, + cwd: 'C:\\Work\\Orca\\App', + filePath: 'C:\\Users\\Ada\\.claude\\four.jsonl', + text: 'harbor windows lane' + }, + // A space in the path, which is what a quoted operator value exists for. + { + id: 5, + cwd: '/Users/ada/My Project', + filePath: '/Users/ada/.claude/projects/c/five.jsonl', + text: 'harbor quay ledger' + } +] + +// Each of these matched in the panel and missed in the index while the engine +// tried to say `repo:` / `path:` in SQL. +const QUERIES = [ + 'harbor path:jsonl', + 'harbor repo:orca/session-search', + 'harbor path:CAFÉ', + 'harbor path:/Users/Ada/orca', + 'harbor repo:app', + 'harbor repo:Orca/App', + 'harbor path:.codex', + 'harbor path:/srv repo:other/service', + 'harbor repo:session-search path:jsonl', + 'harbor path:"/Users/ada/work"', + 'harbor repo:nothing-here', + 'harbor path:one.jsonl path:two.jsonl', + 'harbor path:"/Users/ada/My Project"', + 'harbor repo:"ada/My Project"', + 'harbor' +] + +function asSession(fixture: Fixture): AiVaultSession { + const at = '2026-09-01T00:00:00.000Z' + return { + id: String(fixture.id), + executionHostId: 'local', + agent: 'claude', + sessionId: String(fixture.id), + title: 'fixture', + cwd: fixture.cwd, + branch: null, + model: null, + filePath: fixture.filePath, + codexHome: null, + createdAt: at, + updatedAt: at, + modifiedAt: at, + messageCount: 1, + totalTokens: 0, + previewMessages: [{ role: 'user', text: fixture.text }], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: '', + subagent: null + } as AiVaultSession +} + +/** + * The panel's own answer. The whole query, not the operators cut out of it: a + * whitespace split would cut a quoted value in half, and every fixture's preview + * holds `harbor`, so the free text the panel also applies selects all of them. + */ +function sidebarIds(query: string): string[] { + return filterAiVaultSessions(SESSIONS.map(asSession), { + query, + agents: ['claude'], + scope: 'all', + sort: 'updated', + activeWorktreePaths: [], + hideEmptySessions: false + }) + .map((session) => session.sessionId) + .sort() +} + +it.each(QUERIES)('answers %s the way the sessions panel does', async (query) => { + harness = await openSessionSearchHarness('ss-sidebar-parity') + for (const fixture of SESSIONS) { + addSyntheticSession(harness.db, { + id: fixture.id, + cwd: fixture.cwd, + text: fixture.text, + filePath: fixture.filePath, + sessionFilePath: fixture.filePath + }) + } + const engineIds = harness.engine + .search({ query, limit: 100 }) + .hits.map((hit) => hit.sessionId) + .sort() + expect(engineIds).toEqual(sidebarIds(query)) +}) + +it('is not vacuous: these queries do select, and reject, real sessions', () => { + // A parity suite where every query matched everything, or nothing, would pass + // against any predicate at all. + const answers = QUERIES.map((query) => sidebarIds(query).length) + expect(answers.some((count) => count > 0 && count < SESSIONS.length)).toBe(true) + expect(answers.some((count) => count === 0)).toBe(true) +}) diff --git a/src/main/ai-vault-search/session-search-snippet-marks.test.ts b/src/main/ai-vault-search/session-search-snippet-marks.test.ts new file mode 100644 index 00000000000..d4237ce8b6f --- /dev/null +++ b/src/main/ai-vault-search/session-search-snippet-marks.test.ts @@ -0,0 +1,144 @@ +import { afterEach, expect, it } from 'vitest' +import { + SESSION_SEARCH_SNIPPET_MARK_CLOSE, + SESSION_SEARCH_SNIPPET_MARK_OPEN +} from './session-search-engine-types' +import { + addSyntheticSession, + openSessionSearchHarness, + type SessionSearchHarness +} from './session-search-engine-test-fixture' + +// A snippet has to name which of a row's four columns matched, and the marks +// FTS5 wraps a match in are the only signal. Searching the marked text for the +// public `[[` reads a transcript's own brackets as a highlight — and transcripts +// are full of them, because a bash `[[ -f x ]]` and numpy's `[[1, 2]]` are +// exactly the sort of thing an agent session holds. Whether a column matched is +// the difference between two renderings of the same text instead. + +let harness: SessionSearchHarness | null = null + +afterEach(async () => { + await harness?.close() + harness = null +}) + +const BASH = 'run this: if [[ -f /home/me/.aws/credentials ]]; then cat it; fi' +const TOOL = 'zebrafish appears only in the tool output here' + +it('shows the column that matched, not the one that happens to contain brackets', async () => { + harness = await openSessionSearchHarness('ss-snippet-marks') + // Session 1's match is in tool output while its user turn holds a bash test + // expression; session 2 is the same match with no brackets anywhere. + addSyntheticSession(harness.db, { id: 1, text: BASH, toolText: TOOL }) + addSyntheticSession(harness.db, { id: 2, text: 'run this script please', toolText: TOOL }) + + const hits = harness.engine.search({ query: 'zebrafish' }).hits + expect(hits).toHaveLength(2) + for (const hit of hits) { + expect(hit.evidence?.snippet).toContain( + `${SESSION_SEARCH_SNIPPET_MARK_OPEN}zebrafish${SESSION_SEARCH_SNIPPET_MARK_CLOSE}` + ) + expect(hit.evidence?.snippet).not.toContain('credentials') + } +}) + +it('falls back to any column for an identifier-only match, brackets or not', async () => { + // `zebra` reaches this row only through the identifier shadow column, which is + // what column -1 exists for. The user turn holds numpy output, so a bracket + // scan would have stopped at it and shown a column with no match in it. + harness = await openSessionSearchHarness('ss-snippet-marks-fallback') + addSyntheticSession(harness.db, { + id: 1, + text: 'numpy printed [[1, 2], [3, 4]] before the call', + toolText: 'zebra-fish-count = 4' + }) + + const [hit] = harness.engine.search({ query: 'zebra' }).hits + expect(hit?.evidence?.snippet).toContain( + `${SESSION_SEARCH_SNIPPET_MARK_OPEN}zebra${SESSION_SEARCH_SNIPPET_MARK_CLOSE}` + ) + expect(hit?.evidence?.snippet).not.toContain('numpy') +}) + +it('leaves a transcript’s own brackets in the text it shows', async () => { + // The marks are rewritten from private-use code points at the very end, so a + // row that both matches and contains `[[` keeps its own characters. + harness = await openSessionSearchHarness('ss-snippet-marks-literal') + addSyntheticSession(harness.db, { id: 1, text: `zebrafish ${BASH}` }) + + const [hit] = harness.engine.search({ query: 'zebrafish' }).hits + expect(hit?.evidence?.snippet).toContain( + `${SESSION_SEARCH_SNIPPET_MARK_OPEN}zebrafish${SESSION_SEARCH_SNIPPET_MARK_CLOSE}` + ) + expect(hit?.evidence?.snippet).toContain('[[ -f') +}) + +it('picks by comparison, so a private-use code point in content cannot pose as a mark', async () => { + // The marks are private-use code points, and a transcript may hold one: + // agent output carries Nerd Font glyphs, which live in the same block. So the + // column is chosen by comparing a marked rendering against an unmarked one, + // not by looking for a mark in the text. + harness = await openSessionSearchHarness('ss-snippet-marks-private-use') + addSyntheticSession(harness.db, { + id: 1, + text: 'the \uE000 glyph a font printed here', + toolText: TOOL + }) + + const [hit] = harness.engine.search({ query: 'zebrafish' }).hits + expect(hit?.evidence?.snippet).toContain('zebrafish') + expect(hit?.evidence?.snippet).not.toContain('glyph') +}) + +it('truncates on the last real mark, not on a bracket the transcript wrote', async () => { + // Over the character ceiling the snippet is cut, and it must not cut between + // an open mark and its close. Finding that open mark by searching for `[[` + // stops at the transcript's own bracket instead and throws away everything + // after it. + harness = await openSessionSearchHarness('ss-snippet-marks-truncation') + const long = (letter: string): string => + Array.from({ length: 5 }, () => `${letter.repeat(55)}/tail`).join(' ') + addSyntheticSession(harness.db, { + id: 1, + text: `zebrafish ${long('p')} [[ ${long('q')}` + }) + + const snippet = harness.engine.search({ query: 'zebrafish' }).hits[0]?.evidence?.snippet ?? '' + expect(snippet).toContain('[[zebrafish]]') + // The cut is the character ceiling, so the text after the transcript's own + // bracket survives up to it. + expect(snippet).toContain('qqqqq') +}) + +it('marks only what FTS5 marked, so a glyph in the text stays a glyph', async () => { + // The marked and plain renderings are compared character by character, so a + // private-use code point the transcript wrote has a counterpart in both and + // is text; replacing every one of them would show it as a highlight. + harness = await openSessionSearchHarness('ss-snippet-marks-literal-private-use') + addSyntheticSession(harness.db, { id: 1, text: 'a \uE000 glyph then zebrafish and \uE001 after' }) + + const snippet = harness.engine.search({ query: 'zebrafish' }).hits[0]?.evidence?.snippet ?? '' + expect(snippet).toContain( + `${SESSION_SEARCH_SNIPPET_MARK_OPEN}zebrafish${SESSION_SEARCH_SNIPPET_MARK_CLOSE}` + ) + expect(snippet).toContain('a \uE000 glyph') + expect(snippet).toContain('\uE001 after') + // One highlight, and only one: the literals are not a second pair. + expect(snippet.split(SESSION_SEARCH_SNIPPET_MARK_OPEN)).toHaveLength(2) +}) + +it('does not cut a snippet at a private-use code point the transcript wrote', async () => { + // The balance check looks for the last open mark, and a content glyph is not + // one; treating it as one throws away every character after it. + harness = await openSessionSearchHarness('ss-snippet-marks-literal-truncation') + const long = (letter: string): string => + Array.from({ length: 5 }, () => `${letter.repeat(55)}/tail`).join(' ') + addSyntheticSession(harness.db, { id: 1, text: `zebrafish ${long('p')} \uE000 ${long('q')}` }) + + const snippet = harness.engine.search({ query: 'zebrafish' }).hits[0]?.evidence?.snippet ?? '' + expect(snippet).toContain( + `${SESSION_SEARCH_SNIPPET_MARK_OPEN}zebrafish${SESSION_SEARCH_SNIPPET_MARK_CLOSE}` + ) + expect(snippet).toContain('qqqqq') +}) diff --git a/src/main/ai-vault-search/session-search-snippet.ts b/src/main/ai-vault-search/session-search-snippet.ts new file mode 100644 index 00000000000..1204cdd10a8 --- /dev/null +++ b/src/main/ai-vault-search/session-search-snippet.ts @@ -0,0 +1,171 @@ +import type SyncDatabase from '../sqlite/sync-database' +import { + SESSION_SEARCH_SNIPPET_MARK_CLOSE, + SESSION_SEARCH_SNIPPET_MARK_OPEN +} from './session-search-engine-types' +import { + orExpression, + scopedExpression, + type SessionSearchQueryPlan +} from './session-search-query-planner' +import type { SessionSearchScope } from './session-search-engine-types' + +// What FTS5 wraps a match in before this module rewrites it to the public +// marks. Private-use code points, and not `[[`, because two different jobs here +// have to tell a mark from content: choosing the column to show, and refusing +// to cut a snippet between an open mark and its close. Transcripts contain +// `[[` — a bash `[[ -f x ]]`, numpy's `[[1, 2]]` — and a mark the content can +// forge makes both of those decisions wrong on real text. +const MARK_OPEN = '\uE000' +const MARK_CLOSE = '\uE001' + +const SNIPPET_TOKENS = 12 +// Why a ceiling on top of the token count: a transcript chunk can be 8000 +// characters with no separator in it, which FTS5 reports as one token, so +// "twelve tokens" is not by itself a bound on what a hit carries. +const SNIPPET_MAX_CHARS = 512 + +export type SessionSearchSnippet = { + text: string + truncated: boolean +} + +export const EMPTY_SNIPPET: SessionSearchSnippet = { text: '', truncated: false } + +/** + * The window of one message that shows why it matched. + * + * The expression is the plan's OR form rather than the route's, so a hit found + * through typo repair is marked with the repaired terms it was actually + * retrieved by, and a phrase hit still marks each of its words. + */ +export function sessionSearchSnippet( + db: SyncDatabase, + scope: SessionSearchScope, + rowid: number, + plan: SessionSearchQueryPlan +): SessionSearchSnippet { + // Why: the identifier shadow column is word soup; a hit that also matches in a + // prose column should be shown from there. Column -1 (any column) is the + // fallback for rows that only matched through the shadow column. + // + // The same four for every scope, because the scope is already in the + // expression below. A conversation snippet cannot come out of `tool_text` for + // the reason the search could not: the row has to match + // `{user_text assistant_text}: …` before any of these columns is read, and a + // row that matches under that filter carries its mark in column 0 or 1. A + // second list here would be a guard with nothing left to guard, and the two + // would mask each other's mistakes. + const columns = [0, 1, 2, -1] + // Each column twice: once marked, once with empty marks. Whether a column + // matched is then the difference between two renderings of the same text, + // which content cannot forge — searching the marked one for a mark reads a + // transcript's own `[[` as a highlight and shows a column that matched + // nothing. + const select = columns + .flatMap((column, index) => [ + `snippet(messages_fts, ${column}, '${MARK_OPEN}', '${MARK_CLOSE}', '…', ${SNIPPET_TOKENS}) AS c${index}`, + `snippet(messages_fts, ${column}, '', '', '…', ${SNIPPET_TOKENS}) AS p${index}` + ]) + .join(', ') + try { + // Why the subselect: a bound `rowid = ?` or `rowid IN (?)` next to MATCH is + // silently ignored by the FTS5 planner, which then returns the first match + // in the table. Why the join to `sessions`: retrieval proved this rowid + // belonged to a live session, but a purge can commit between that statement + // and this one, and a message row outlives its session row until the drain + // reaches it. INNER, never LEFT — this is the last read before content is + // returned to a caller. + const row = db + .prepare( + `SELECT ${select} FROM messages_fts + JOIN messages m ON m.id = messages_fts.rowid + JOIN sessions s ON s.id = m.session_row_id + WHERE messages_fts MATCH ? AND messages_fts.rowid IN (SELECT ?)` + ) + .get(scopedExpression(scope, orExpression(plan.terms)), rowid) as + | Record + | undefined + if (!row) { + return EMPTY_SNIPPET + } + // A snippet with nothing highlighted tells the user nothing; omit it. + const index = columns.findIndex( + (_column, at) => row[`c${at}`] !== undefined && row[`c${at}`] !== row[`p${at}`] + ) + if (index === -1) { + return EMPTY_SNIPPET + } + const pieces = splitMarks(row[`c${index}`]!, row[`p${index}`]!) + return pieces === null ? EMPTY_SNIPPET : renderSnippet(pieces) + } catch { + return EMPTY_SNIPPET + } +} + +/** One run of the snippet's own text, or one mark FTS5 put between two runs. */ +type SnippetPiece = { kind: 'text'; value: string } | { kind: 'mark'; value: string } + +/** + * The marked rendering as its text and the marks FTS5 inserted into it. + * + * A mark is a private-use character the marked rendering has where the plain one + * has something else, so a Nerd Font glyph the transcript itself wrote stays + * text — replacing every private-use character would hand the renderer a + * highlight the content forged. Null when the two renderings differ for any + * other reason, which is not a difference this can attribute. + */ +function splitMarks(marked: string, plain: string): SnippetPiece[] | null { + const pieces: SnippetPiece[] = [] + const rest = [...plain] + let at = 0 + let run = '' + for (const point of marked) { + if (point === rest[at]) { + run += point + at++ + continue + } + if (point !== MARK_OPEN && point !== MARK_CLOSE) { + return null + } + pieces.push({ kind: 'text', value: run }, { kind: 'mark', value: point }) + run = '' + } + if (at !== rest.length) { + return null + } + pieces.push({ kind: 'text', value: run }) + return pieces +} + +/** + * The public marks, and the character ceiling. + * + * Cut on a code-point boundary, and never between a mark and its close: an open + * mark with no close hands the renderer something it can never close. The + * ceiling counts the snippet's own characters, so the marks cost the caller + * nothing and a transcript's own private-use character costs it one. + */ +function renderSnippet(pieces: SnippetPiece[]): SessionSearchSnippet { + let text = '' + let shown = 0 + let openedAt: number | null = null + for (const piece of pieces) { + if (piece.kind === 'mark') { + const open = piece.value === MARK_OPEN + openedAt = open ? text.length : null + text += open ? SESSION_SEARCH_SNIPPET_MARK_OPEN : SESSION_SEARCH_SNIPPET_MARK_CLOSE + continue + } + const points = [...piece.value] + if (shown + points.length <= SNIPPET_MAX_CHARS) { + shown += points.length + text += piece.value + continue + } + text += points.slice(0, SNIPPET_MAX_CHARS - shown).join('') + return { text: openedAt === null ? text : text.slice(0, openedAt), truncated: true } + } + return { text, truncated: false } +} diff --git a/src/main/ai-vault-search/session-search-source-presence.ts b/src/main/ai-vault-search/session-search-source-presence.ts new file mode 100644 index 00000000000..cc7155fccc8 --- /dev/null +++ b/src/main/ai-vault-search/session-search-source-presence.ts @@ -0,0 +1,40 @@ +import type SyncDatabase from '../sqlite/sync-database' +import type { SessionSearchSourcePresence } from './session-search-engine-types' + +/** + * Where each session's source stands, read from the index's own `files` table. + * + * Why not a stat: a search page of 20 hits would be 20 filesystem round trips + * on the query path, and on an SSH or WSL host each one can block for as long + * as the connection takes to answer — the reviewer's F11. The index already + * records what discovery last proved about every file it read, so the query + * path reads that instead of asking the disk again. + * + * The vocabulary is deliberately short of `missing`. A row here means the index + * holds a live file record for the session, which is `present`. No row means + * this read cannot tell whether the source is gone or merely unrecorded, and + * loss of contact is never evidence of absence + * (docs/reference/ssh-execution-boundary.md), so it is `unverifiable`. Proving + * a deletion is the indexer's job and it retires the session's rows outright. + */ +export function sessionSourcePresence( + db: SyncDatabase, + sessionRowIds: readonly number[] +): Map { + const presence = new Map( + sessionRowIds.map((id) => [id, 'unverifiable' as const]) + ) + if (sessionRowIds.length === 0) { + return presence + } + const rows = db + .prepare( + `SELECT DISTINCT session_row_id FROM files + WHERE session_row_id IN (${sessionRowIds.map(() => '?').join(',')})` + ) + .all(...sessionRowIds) as { session_row_id: number }[] + for (const row of rows) { + presence.set(row.session_row_id, 'present') + } + return presence +} diff --git a/src/main/ai-vault-search/session-search-typo-policy.test.ts b/src/main/ai-vault-search/session-search-typo-policy.test.ts new file mode 100644 index 00000000000..938c1e1fc3e --- /dev/null +++ b/src/main/ai-vault-search/session-search-typo-policy.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import type SyncDatabase from '../sqlite/sync-database' +import { openSessionSearchIndexFile } from './session-search-index-test-fixture' +import { ensureSessionSearchQuerySchema } from './session-search-query-schema' +import { SessionSearchTypoRepair } from './session-search-typo-repair' + +/** A session row the planted messages below hang off, so a repair can see them. */ +function addSession(db: SyncDatabase, id: number): void { + db.prepare( + `INSERT INTO sessions(id,agent,session_id,file_path,title,resume_command) + VALUES (?, 'claude', ?, '/synthetic/fixture', 'typo fixture', '')` + ).run(id, String(id)) +} + +function addTerm(db: SyncDatabase, sessionRowId: number, term: string): void { + const rowid = db + .prepare("INSERT INTO messages(session_row_id, role) VALUES (?, 'user')") + .run(sessionRowId).lastInsertRowid + db.prepare('INSERT INTO messages_fts(rowid, user_text) VALUES (?, ?)').run(Number(rowid), term) +} + +describe('typo repair policy', () => { + it.each([ + { input: 'coalesces', candidate: 'coalesced', copies: 2, exact: true, expected: null }, + { input: 'coalescs', candidate: 'coalesces', copies: 1, exact: false, expected: null }, + { input: 'coalescs', candidate: 'coalesces', copies: 2, exact: false, expected: 'coalesces' }, + { input: 'café', candidate: 'cafe', copies: 1, exact: false, expected: null }, + { input: 'car', candidate: 'cars', copies: 2, exact: false, expected: null }, + { input: 'calm', candidate: 'clam', copies: 2, exact: false, expected: null } + ])( + 'repairs $input to $expected with $copies postings (exact=$exact)', + async ({ input, candidate, copies, exact, expected }) => { + const index = await openSessionSearchIndexFile('ss-typo-policy') + try { + ensureSessionSearchQuerySchema(index.db) + addSession(index.db, 1) + for (let i = 0; i < copies; i++) { + addTerm(index.db, 1, candidate) + } + if (exact) { + addTerm(index.db, 1, input) + } + expect(new SessionSearchTypoRepair(index.db).correct(input, 'all')).toBe(expected) + } finally { + await index.close() + } + } + ) + + // A purge cuts a session loose in one transaction and reclaims its rows over + // many, so the vocabulary can still list a term whose only rows nothing can + // reach. Abandoning the prefix at that term would lose a repair the rest of + // the index can already serve. + it('falls through to the best candidate a reader can still reach', async () => { + const index = await openSessionSearchIndexFile('ss-typo-orphaned') + try { + const { db } = index + ensureSessionSearchQuerySchema(db) + addSession(db, 1) + // `coalesces` scores higher against `coalescs` than `coalesced` does, and + // shares its prefix, so only the fall-through can reach the reachable one. + // Session 2 is never created: these rows are what an unfinished purge + // leaves behind, and the vocabulary counts them all the same. + for (const [term, session] of [ + ['coalesces', 2], + ['coalesces', 2], + ['coalesced', 1], + ['coalesced', 1] + ] as const) { + addTerm(db, session, term) + } + expect(db.prepare("SELECT doc FROM messages_vocab WHERE term='coalesces'").get()).toEqual({ + doc: 2 + }) + expect(new SessionSearchTypoRepair(db).correct('coalescs', 'all')).toBe('coalesced') + } finally { + await index.close() + } + }) +}) diff --git a/src/main/ai-vault-search/session-search-typo-repair.ts b/src/main/ai-vault-search/session-search-typo-repair.ts new file mode 100644 index 00000000000..1aed90e2991 --- /dev/null +++ b/src/main/ai-vault-search/session-search-typo-repair.ts @@ -0,0 +1,163 @@ +import type SyncDatabase from '../sqlite/sync-database' +import type { SessionSearchScope } from './session-search-engine-types' +import { quoteFtsTerm, scopedExpression } from './session-search-query-planner' + +// Why: a query term with zero postings is usually a typo. The index's own +// vocabulary (fts5vocab) is the dictionary, so repair needs no model and can +// never suggest a word the index does not contain. Measured MRR 0.553 → 0.566. +const MIN_TERM_LENGTH = 4 +const MAX_TERM_LENGTH = 40 +const LENGTH_SLACK = 2 +const MIN_DOC_FREQUENCY = 2 +const MIN_SIMILARITY = 0.82 +const MAX_CANDIDATES = 4000 +// Candidates counted against live rows per prefix before giving up on it. Only +// reached for a term the scope has no posting for, which is the rare case. +const MAX_VISIBILITY_PROBES = 8 +// How far a live count walks before it stops caring. It exists to break ties +// between candidates of equal similarity, and the difference between a term in +// sixty-four rows and one in six thousand does not change which is the better +// repair — but reading either in full would. +const MAX_COUNTED_ROWS = 64 + +// Longest common subsequence length; the indel distance is len(a)+len(b)-2·LCS. +function commonSubsequenceLength(a: string, b: string): number { + let previous = Array.from({ length: b.length + 1 }).fill(0) + let current = Array.from({ length: b.length + 1 }).fill(0) + for (let i = 1; i <= a.length; i += 1) { + for (let j = 1; j <= b.length; j += 1) { + current[j] = + a.charCodeAt(i - 1) === b.charCodeAt(j - 1) + ? previous[j - 1] + 1 + : Math.max(previous[j], current[j - 1]) + } + ;[previous, current] = [current, previous] + } + return previous[b.length] +} + +/** Normalized indel similarity in [0, 1], the scale rapidfuzz's `fuzz.ratio` uses. */ +function similarity(a: string, b: string): number { + const total = a.length + b.length + return total === 0 ? 1 : (2 * commonSubsequenceLength(a, b)) / total +} + +/** + * Spelling repair over the index's own vocabulary. + * + * The vocabulary proposes and a scoped count disposes. `messages_vocab` is a + * view over the whole FTS b-tree: it has no column filter, because fts5vocab is + * per table, and it counts rows whose session a purge already cut loose. So + * every decision that reaches the plan — whether a term is already spelled + * right, whether a candidate is eligible, and which of two equally close + * candidates wins — is taken from a `messages_fts MATCH` under the same column + * filter retrieval uses, joined to `sessions`. + * + * That is not tidiness. Reading the vocabulary directly made the repair depend + * on rows the search could never return: tool output suppressed a + * conversation-scope repair and supplied suggestions the scope would never + * show, and retention's orphan drain silently changed which word a query was + * repaired to. + * + * The cost is one bounded count per candidate examined, at most + * `MAX_VISIBILITY_PROBES` per prefix, and only for a term the scope has no + * posting for. See docs/reference/agent-session-search-query-tuning.md. + */ +export class SessionSearchTypoRepair { + private readonly liveRows: ReturnType + private readonly candidatesByPrefix: ReturnType + + constructor(db: SyncDatabase) { + this.liveRows = db.prepare( + `SELECT count(*) AS rows FROM ( + SELECT m.id FROM messages_fts + JOIN messages m ON m.id = messages_fts.rowid + JOIN sessions s ON s.id = m.session_row_id + WHERE messages_fts MATCH ? LIMIT ${MAX_COUNTED_ROWS})` + ) + // fts5vocab is ordered by term, so a prefix range plus a length band is a + // bounded scan and no sort. Ordered by term rather than by `doc`: the + // ordering decides which candidates survive the limit, and `doc` counts + // rows no reader can see, so the drain reclaiming them moved the cut. + this.candidatesByPrefix = db.prepare( + `SELECT term FROM messages_vocab + WHERE term >= ? AND term < ? AND length(term) BETWEEN ? AND ? + ORDER BY term LIMIT ?` + ) + } + + /** Live rows carrying this term inside `scope`, counted no further than it matters. */ + private countRows(term: string, scope: SessionSearchScope): number { + const row = this.liveRows.get(scopedExpression(scope, quoteFtsTerm(term))) as { rows: number } + return row.rows + } + + /** Whether a live row inside `scope` holds this term. */ + hasPostings(term: string, scope: SessionSearchScope): boolean { + return this.countRows(term, scope) > 0 + } + + /** Returns the closest indexed term, or null when `term` exists or nothing is close enough. */ + correct(term: string, scope: SessionSearchScope): string | null { + const lowered = term.toLowerCase() + if (lowered.length < MIN_TERM_LENGTH || lowered.length > MAX_TERM_LENGTH) { + return null + } + if (this.hasPostings(lowered, scope)) { + return null + } + // Two-letter prefix first (a typo rarely hits both), then the transposed + // pair, then the bare first letter as the wide fallback. + const prefixes = [lowered.slice(0, 2), lowered[1] + lowered[0], lowered[0]] + for (const prefix of prefixes) { + const best = this.bestVisible(lowered, prefix, scope) + if (best) { + return best + } + } + return null + } + + /** + * The closest candidate at `prefix` that this scope can actually answer with. + * + * Ranking is pure CPU, so the walk is bounded rather than the count: the + * closest term can be one the scope never shows, and abandoning the prefix + * there would lose a repair the rest of the index can serve. Ties on + * similarity go to the more common word, which is the same prior the + * vocabulary's `doc` used to supply — counted live here so the answer does + * not move when a purge reclaims rows nothing could reach. + */ + private bestVisible(lowered: string, prefix: string, scope: SessionSearchScope): string | null { + const counted = this.ranked(lowered, prefix) + .slice(0, MAX_VISIBILITY_PROBES) + .map((candidate) => ({ ...candidate, rows: this.countRows(candidate.term, scope) })) + .filter((candidate) => candidate.rows >= MIN_DOC_FREQUENCY) + if (counted.length === 0) { + return null + } + // Already sorted by similarity; a stable sort keeps that and orders the ties. + return counted.sort((left, right) => right.score - left.score || right.rows - left.rows)[0]! + .term + } + + /** Candidates similar enough to be a repair, closest first. */ + private ranked(lowered: string, prefix: string): { term: string; score: number }[] { + return this.candidates(prefix, lowered.length) + .map((row) => ({ term: row.term, score: similarity(lowered, row.term) })) + .filter((candidate) => candidate.score >= MIN_SIMILARITY) + .sort((left, right) => right.score - left.score || (left.term < right.term ? -1 : 1)) + } + + private candidates(prefix: string, length: number): { term: string }[] { + const last = prefix.charCodeAt(prefix.length - 1) + const upper = prefix.slice(0, -1) + String.fromCharCode(last + 1) + return this.candidatesByPrefix.all( + prefix, + upper, + Math.max(MIN_TERM_LENGTH - 1, length - LENGTH_SLACK), + length + LENGTH_SLACK, + MAX_CANDIDATES + ) as { term: string }[] + } +} diff --git a/src/main/ai-vault-search/session-search-typo-scope.test.ts b/src/main/ai-vault-search/session-search-typo-scope.test.ts new file mode 100644 index 00000000000..98e3938178e --- /dev/null +++ b/src/main/ai-vault-search/session-search-typo-scope.test.ts @@ -0,0 +1,71 @@ +import { afterEach, expect, it } from 'vitest' +import { + addSyntheticSession, + openSessionSearchHarness, + type SessionSearchHarness +} from './session-search-engine-test-fixture' + +// Typo repair used to read `messages_vocab` and probe `messages_fts` with no +// column filter, so tool output decided whether a conversation-scoped query was +// repaired — in both directions. A tool row carrying the misspelling made the +// query look correctly spelled and suppressed the repair; a tool row carrying a +// rare word offered it as the suggestion, naming in `repairedTerms` a string +// from a column the scope will never show. + +let harness: SessionSearchHarness | null = null +let control: SessionSearchHarness | null = null + +afterEach(async () => { + await harness?.close() + await control?.close() + harness = null + control = null +}) + +it('repairs a conversation query the same way with or without a tool row', async () => { + harness = await openSessionSearchHarness('ss-typo-scope-suppress') + addSyntheticSession(harness.db, { id: 1, text: 'we changed resolveTerminalPath today', rows: 2 }) + // A second session whose tool output happens to contain the misspelling. + addSyntheticSession(harness.db, { + id: 2, + text: 'ran the linter', + toolText: 'warning: unknown symbol resolveterminalpth in build log', + rows: 2, + role: 'assistant' + }) + + // The same index without that one tool row. + control = await openSessionSearchHarness('ss-typo-scope-control') + addSyntheticSession(control.db, { id: 1, text: 'we changed resolveTerminalPath today', rows: 2 }) + addSyntheticSession(control.db, { id: 2, text: 'ran the linter' }) + + const request = { query: 'resolveterminalpth', scope: 'conversation' } as const + const withTool = harness.engine.search(request) + const clean = control.engine.search(request) + + expect(clean.planner.repairedTerms).toEqual(['resolveterminalpath']) + expect(clean.hits.map((hit) => hit.sessionId)).toEqual(['1']) + expect(withTool.planner.repairedTerms).toEqual(clean.planner.repairedTerms) + expect(withTool.hits.map((hit) => hit.sessionId)).toEqual(clean.hits.map((hit) => hit.sessionId)) +}) + +it('never repairs a conversation query onto a word only tool output holds', async () => { + harness = await openSessionSearchHarness('ss-typo-scope-leak') + addSyntheticSession(harness.db, { + id: 1, + text: 'ran the deploy', + toolText: 'AWS_SESSION_TOKEN=quicksilverfox expired', + rows: 2, + role: 'assistant' + }) + addSyntheticSession(harness.db, { id: 2, text: 'ordinary prose about nothing' }) + + const narrowed = harness.engine.search({ query: 'quicksilverfx', scope: 'conversation' }) + expect(narrowed.planner.repairedTerms).toBeUndefined() + expect(narrowed.hits).toEqual([]) + // The same query over the whole corpus still finds it, which is the scope + // doing its job rather than the repair being broken. + const wide = harness.engine.search({ query: 'quicksilverfx', scope: 'all' }) + expect(wide.planner.repairedTerms).toEqual(['quicksilverfox']) + expect(wide.hits.map((hit) => hit.sessionId)).toEqual(['1']) +}) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-open.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-open.test.ts index 7bfd72f9b51..45f1edbe6fb 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-open.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-open.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Worker } from 'node:worker_threads' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { AiVaultScanIssue } from '../../shared/ai-vault-types' import Database from '../sqlite/sync-database' import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-list' @@ -20,6 +20,7 @@ let tempDirs: string[] = [] let lockHolders: Worker[] = [] afterEach(async () => { + vi.restoreAllMocks() await Promise.all(lockHolders.splice(0).map((worker) => worker.terminate())) lockHolders = [] for (const dir of tempDirs) { @@ -66,20 +67,29 @@ const LOCK_HOLDER_SOURCE = ` db.exec('BEGIN EXCLUSIVE') db.exec("INSERT INTO session (id, time_created, time_updated) VALUES ('locked-write', 1, 1)") parentPort.postMessage('locked') - setTimeout(() => { - db.exec('ROLLBACK') - db.close() - parentPort.postMessage('released') - }, workerData.holdMs) + parentPort.once('message', (message) => { + if (message !== 'reader-started') { + throw new Error('Unexpected lock-holder message') + } + setTimeout(() => { + db.exec('ROLLBACK') + db.close() + parentPort.postMessage('released') + }, workerData.releaseDelayMs) + }) ` -async function holdWriteLock(path: string, holdMs: number): Promise { - const worker = new Worker(LOCK_HOLDER_SOURCE, { eval: true, workerData: { path, holdMs } }) +async function holdWriteLock(path: string, releaseDelayMs: number): Promise { + const worker = new Worker(LOCK_HOLDER_SOURCE, { + eval: true, + workerData: { path, releaseDelayMs } + }) lockHolders.push(worker) await new Promise((resolve, reject) => { worker.once('message', () => resolve()) worker.once('error', reject) }) + return worker } describe('listOpenCodeSqliteSessions against a database OpenCode is writing to', () => { @@ -117,9 +127,9 @@ describe('listOpenCodeSqliteSessions against a database OpenCode is writing to', it('reads the sessions once the write finishes inside the busy timeout', async () => { const path = seededDatabase('opencode.db', 'session-a') - // Long enough that only a real busy timeout — not a lucky fast open — survives it. - await holdWriteLock(path, 900) + const worker = await holdWriteLock(path, 200) const issues: AiVaultScanIssue[] = [] + worker.postMessage('reader-started') const candidates = await listOpenCodeSqliteSessions({ dbPaths: [path], limit: 10, issues }) @@ -146,6 +156,63 @@ describe('readOpenCodeDatabase', () => { expect(() => captured!.prepare('SELECT 1')).toThrow(/not open/i) }) + it('closes the handle when query_only setup fails', () => { + const path = seededDatabase('opencode.db', 'session-a') + const setupError = new Error('query_only setup failed') + const originalClose = Database.prototype.close + const pragmaSpy = vi.spyOn(Database.prototype, 'pragma').mockImplementationOnce(() => { + throw setupError + }) + const closeSpy = vi.spyOn(Database.prototype, 'close') + const read = vi.fn() + + try { + expect(() => readOpenCodeDatabase({ dbPath: path, read })).toThrow(setupError) + expect(read).not.toHaveBeenCalled() + expect(closeSpy).toHaveBeenCalledOnce() + expect(() => (pragmaSpy.mock.contexts[0] as Database).prepare('SELECT 1')).toThrow( + /not open/i + ) + } finally { + try { + originalClose.call(pragmaSpy.mock.contexts[0] as Database) + } catch { + // Keep the regression safe to run against the leaking implementation too. + } + } + }) + + it('preserves the setup error when closing also fails', () => { + const path = seededDatabase('opencode.db', 'session-a') + const setupError = new Error('query_only setup failed') + const closeError = new Error('close failed') + const originalClose = Database.prototype.close + vi.spyOn(Database.prototype, 'pragma').mockImplementationOnce(() => { + throw setupError + }) + vi.spyOn(Database.prototype, 'close').mockImplementationOnce(function (this: Database) { + originalClose.call(this) + throw closeError + }) + + const read = vi.fn() + expect(() => readOpenCodeDatabase({ dbPath: path, read })).toThrow(setupError) + expect(Database.prototype.close).toHaveBeenCalledOnce() + expect(read).not.toHaveBeenCalled() + }) + + it('keeps the query-only guard enabled for successful reads', () => { + const path = seededDatabase('opencode.db', 'session-a') + readOpenCodeDatabase({ + dbPath: path, + read: (db) => { + expect(db.pragma('query_only', { simple: true })).toBe(1) + expect(() => db.exec('DELETE FROM session')).toThrow(/readonly/i) + expect(db.prepare('SELECT id FROM session').all()).toEqual([{ id: 'session-a' }]) + } + }) + }) + it('closes the handle when the read throws', () => { const path = seededDatabase('opencode.db', 'session-a') let captured: Database.Database | null = null diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-open.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-open.ts index 84c2cca73d4..0146962fa05 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-open.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-open.ts @@ -41,8 +41,17 @@ function openOpenCodeDatabaseReadonly(dbPath: string): SyncDatabase { fileMustExist: true, timeout: openCodeBusyTimeoutMs(dbPath) }) - db.pragma('query_only = ON') - return db + try { + db.pragma('query_only = ON') + return db + } catch (error) { + try { + db.close() + } catch { + // Why: close must not hide the query_only setup failure. + } + throw error + } } /** diff --git a/src/main/automations/hermes-cron-run-content.ts b/src/main/automations/hermes-cron-run-content.ts index 55b01fb2545..1920e18e0d6 100644 --- a/src/main/automations/hermes-cron-run-content.ts +++ b/src/main/automations/hermes-cron-run-content.ts @@ -1,3 +1,4 @@ +import { HermesSessionRunIndex } from '../../shared/hermes-session-run-index' import { open, readFile, realpath, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { isAbsolute, join, relative, resolve, sep } from 'node:path' @@ -152,59 +153,21 @@ function mergeOutputAndSessionContent( return `${outputContent}\n\n---\n\n${FULL_SESSION_LOG_HEADING}\n\n${sessionContent}` } -function findMatchingSessionRunIndex( - outputRun: unknown, - sessionRuns: unknown[], - usedSessionRunIndexes: Set -): number | null { - const outputRunKey = getRunKey(outputRun) - const exactMatchIndex = sessionRuns.findIndex( - (sessionRun, index) => - !usedSessionRunIndexes.has(index) && getRunKey(sessionRun) === outputRunKey - ) - if (exactMatchIndex !== -1) { - return exactMatchIndex - } - - const outputTime = sortableTimeFromRunKey(outputRunKey) - if (!Number.isFinite(outputTime)) { - return null - } - - let bestIndex: number | null = null - let bestGap = Number.POSITIVE_INFINITY - for (let index = 0; index < sessionRuns.length; index += 1) { - if (usedSessionRunIndexes.has(index)) { - continue - } - const sessionTime = sortableTimeFromRunKey(getRunKey(sessionRuns[index])) - if (!Number.isFinite(sessionTime)) { - continue - } - const gap = outputTime - sessionTime - if (gap < 0 || gap > MAX_SESSION_OUTPUT_GAP_MS || gap >= bestGap) { - continue - } - bestIndex = index - bestGap = gap - } - return bestIndex -} - export function mergeHermesOutputAndSessionRuns( outputRuns: unknown[], sessionRuns: unknown[] ): unknown[] { - const usedSessionRunIndexes = new Set() + const sessionIndex = new HermesSessionRunIndex( + outputRuns.length > 0 ? sessionRuns.map(getRunKey) : [], + sortableTimeFromRunKey, + MAX_SESSION_OUTPUT_GAP_MS + ) + const usedSessionRunIndexes = sessionIndex.used const mergedOutputRuns = outputRuns.map((outputRun) => { if (!isRecord(outputRun)) { return outputRun } - const sessionRunIndex = findMatchingSessionRunIndex( - outputRun, - sessionRuns, - usedSessionRunIndexes - ) + const sessionRunIndex = sessionIndex.find(getRunKey(outputRun)) if (sessionRunIndex === null) { return outputRun } @@ -212,7 +175,7 @@ export function mergeHermesOutputAndSessionRuns( if (!isRecord(sessionRun)) { return outputRun } - usedSessionRunIndexes.add(sessionRunIndex) + sessionIndex.use(sessionRunIndex) // Hermes writes the markdown output at completion, while state.db keeps // the actual turn-by-turn transcript under the cron session start time. return { @@ -234,16 +197,17 @@ export function mergeHermesOutputAndSessionRunRefs( outputRefs: HermesOutputRunRef[], sessionRefs: HermesSessionRunRef[] ): HermesMergedRunRef[] { - const usedSessionRunIndexes = new Set() + const sessionIndex = new HermesSessionRunIndex( + outputRefs.length > 0 ? sessionRefs.map(getRunKey) : [], + sortableTimeFromRunKey, + MAX_SESSION_OUTPUT_GAP_MS + ) + const usedSessionRunIndexes = sessionIndex.used const mergedOutputRefs = outputRefs.map((outputRef) => { - const sessionRunIndex = findMatchingSessionRunIndex( - outputRef, - sessionRefs, - usedSessionRunIndexes - ) + const sessionRunIndex = sessionIndex.find(getRunKey(outputRef)) const sessionRef = sessionRunIndex === null ? null : sessionRefs[sessionRunIndex] if (sessionRunIndex !== null) { - usedSessionRunIndexes.add(sessionRunIndex) + sessionIndex.use(sessionRunIndex) } return { id: outputRef.id, diff --git a/src/main/browser/agent-browser-bridge-core-commands.ts b/src/main/browser/agent-browser-bridge-core-commands.ts index aac82f77487..335bacef3b9 100644 --- a/src/main/browser/agent-browser-bridge-core-commands.ts +++ b/src/main/browser/agent-browser-bridge-core-commands.ts @@ -6,14 +6,10 @@ import type { } from '../../shared/runtime-types' import { assertClipboardTextWriteWithinLimitWithYield } from '../../shared/clipboard-text' import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' -import { iterateBrowserTextInsertionChunks } from './browser-text-insertion' import { BrowserError } from './cdp-bridge' import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep' import { focusedValueSetExpression } from './agent-browser-bridge-input' -import { - AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES, - EMBEDDED_NAVIGATION_TIMEOUT_MS -} from './agent-browser-bridge-types' +import { EMBEDDED_NAVIGATION_TIMEOUT_MS } from './agent-browser-bridge-types' import { isAbortedNavigationError, waitForAbortedNavigationReplacement @@ -157,23 +153,10 @@ export abstract class AgentBrowserBridgeCoreCommands extends AgentBrowserBridgeQ async (sessionName) => { if (!(await this.isExplicitContentEditableTarget(sessionName, element))) { await this.execAgentBrowser(sessionName, ['focus', element]) - await this.execAgentBrowser(sessionName, [ - 'eval', - focusedValueSetExpression(JSON.stringify('')) - ]) - for (const chunk of iterateBrowserTextInsertionChunks( - value, - AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES - )) { - await this.execAgentBrowser(sessionName, [ - 'eval', - focusedValueSetExpression(JSON.stringify(chunk), { append: true }) - ]) - } - await this.execAgentBrowser(sessionName, [ - 'eval', - focusedValueSetExpression(JSON.stringify(''), { append: true, dispatchEvents: true }) - ]) + // One stdin edit avoids argv limits and repeated copying of the growing field value. + await this.execAgentBrowser(sessionName, ['eval', '--stdin'], { + stdinText: focusedValueSetExpression(JSON.stringify(value), { dispatchEvents: true }) + }) return { filled: element } as BrowserFillResult } diff --git a/src/main/browser/agent-browser-bridge-text-input.test.ts b/src/main/browser/agent-browser-bridge-text-input.test.ts index 4216a6192be..f083d11067c 100644 --- a/src/main/browser/agent-browser-bridge-text-input.test.ts +++ b/src/main/browser/agent-browser-bridge-text-input.test.ts @@ -278,8 +278,9 @@ describe('AgentBrowserBridge', () => { ) expect(evalCall).toBeDefined() const args = evalCall![1] as string[] - const expression = args[args.indexOf('eval') + 1] - expect(() => new Function(expression)).not.toThrow() + expect(args[args.indexOf('eval') + 1]).toBe('--stdin') + expect(stdinWrites).toHaveLength(1) + expect(() => new Function(stdinWrites[0])).not.toThrow() }) it('replaces contenteditable text through the browser editing pipeline', async () => { @@ -359,12 +360,7 @@ describe('AgentBrowserBridge', () => { await bridge.fill('@spinbutton', '200') - const expressions = execFileMock.mock.calls - .filter((call: unknown[]) => (call[1] as string[]).includes('eval')) - .map((call: unknown[]) => { - const args = call[1] as string[] - return args[args.indexOf('eval') + 1] - }) + const expressions = stdinWrites const input = createFillEvalNode({ tagName: 'INPUT' }) const wrapper = createFillEvalNode({ @@ -389,12 +385,7 @@ describe('AgentBrowserBridge', () => { await bridge.fill('@spinbutton', '200') - const expressions = execFileMock.mock.calls - .filter((call: unknown[]) => (call[1] as string[]).includes('eval')) - .map((call: unknown[]) => { - const args = call[1] as string[] - return args[args.indexOf('eval') + 1] - }) + const expressions = stdinWrites const input = createFillEvalNode({ tagName: 'INPUT' }) const wrapper = createFillEvalNode({ @@ -419,12 +410,7 @@ describe('AgentBrowserBridge', () => { await bridge.fill('@spinbutton', '200') - const expressions = execFileMock.mock.calls - .filter((call: unknown[]) => (call[1] as string[]).includes('eval')) - .map((call: unknown[]) => { - const args = call[1] as string[] - return args[args.indexOf('eval') + 1] - }) + const expressions = stdinWrites const input = createFillEvalNode({ tagName: 'INPUT' }) const controlled = createFillEvalNode({ tagName: 'DIV', descendant: input.node }) @@ -452,12 +438,7 @@ describe('AgentBrowserBridge', () => { await bridge.fill('@spinbutton', '200') - const expressions = execFileMock.mock.calls - .filter((call: unknown[]) => (call[1] as string[]).includes('eval')) - .map((call: unknown[]) => { - const args = call[1] as string[] - return args[args.indexOf('eval') + 1] - }) + const expressions = stdinWrites const hiddenInput = createFillEvalNode({ tagName: 'INPUT', type: 'hidden' }) const numberInput = createFillEvalNode({ tagName: 'INPUT', type: 'number' }) @@ -485,12 +466,7 @@ describe('AgentBrowserBridge', () => { await bridge.fill('@input', '200') - const expressions = execFileMock.mock.calls - .filter((call: unknown[]) => (call[1] as string[]).includes('eval')) - .map((call: unknown[]) => { - const args = call[1] as string[] - return args[args.indexOf('eval') + 1] - }) + const expressions = stdinWrites const input = createFillEvalNode({ tagName: 'INPUT' }) @@ -503,8 +479,8 @@ describe('AgentBrowserBridge', () => { expect(input.events.map((event) => event.type)).toEqual(['input', 'change']) }) - it('chunks large agent-browser fill values before eval transport', async () => { - const text = ['x'.repeat(AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES), 'tail'].join('') + it('fills large plain fields with one stdin edit and one event pair', async () => { + const text = `${'é\n'.repeat(512 * 1024)}tail'\\` succeedWith({ ok: true }) await bridge.fill('@textarea', text) @@ -512,15 +488,17 @@ describe('AgentBrowserBridge', () => { const evalCalls = execFileMock.mock.calls.filter((call: unknown[]) => (call[1] as string[]).includes('eval') ) - const appendExpressions = evalCalls.slice(1, -1).map((call: unknown[]) => { - const args = call[1] as string[] - return args[args.indexOf('eval') + 1] + expect(evalCalls).toHaveLength(1) + expect(evalCalls[0][1]).toContain('--stdin') + expect(stdinWrites).toHaveLength(1) + expect((evalCalls[0][1] as string[]).join('')).not.toContain(text) + const input = createFillEvalNode({ tagName: 'TEXTAREA' }) + runFillEvalExpressions(stdinWrites, { + activeElement: input.node, + getElementById: () => null }) - - expect(appendExpressions).toHaveLength(2) - expect(appendExpressions.some((expression) => expression.includes(text))).toBe(false) - expect(appendExpressions[0]).toContain('x'.repeat(AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES)) - expect(appendExpressions[1]).toContain('tail') + expect(input.value).toBe(text) + expect(input.events.map((event) => event.type)).toEqual(['input', 'change']) }) it.each([ diff --git a/src/main/browser/browser-cookie-clear-preserve.test.ts b/src/main/browser/browser-cookie-clear-preserve.test.ts index 061289b86dd..e082c2eff6f 100644 --- a/src/main/browser/browser-cookie-clear-preserve.test.ts +++ b/src/main/browser/browser-cookie-clear-preserve.test.ts @@ -171,8 +171,9 @@ describe('removeTransplantableCookies — preserved families on a POPULATED jar' }) it('preserves a family named by an IPv4 literal', async () => { - // Why: psl reads 127.0.0.1 as the dotted DNS name '0.1'. If registrableFamily returned that, - // the live 127.0.0.1 session would not match the preserve set and would be erased. + // Why: an IPv4 literal has no registrable domain, so the family must come from the IP branch. + // If registrableFamily fell through to the suffix parser, the live 127.0.0.1 session would not + // match the preserve set and would be erased. const target = jar([cookie('127.0.0.1', 'loopback-session'), cookie('.other.example', 'stale')]) await removeTransplantableCookies( diff --git a/src/main/browser/browser-cookie-import-policy.ts b/src/main/browser/browser-cookie-import-policy.ts index 04a54a47b15..d263af9be23 100644 --- a/src/main/browser/browser-cookie-import-policy.ts +++ b/src/main/browser/browser-cookie-import-policy.ts @@ -1,6 +1,6 @@ import { isIP } from 'node:net' import type { Cookie, Cookies } from 'electron' -import { parse as parseDomain } from 'psl' +import { parse as parseDomain } from 'tldts' // Why: type-only, so this does not create a runtime cycle with the clear module. import type { CookieClearIdentity } from './browser-cookie-import-clear' @@ -41,13 +41,24 @@ export function normalizeCookieDomain(domain: string): string | null { } } +// Why allowPrivateDomains: the PSL's PRIVATE section is what keeps one tenant's cookies out of +// another's — without it `foo.github.io` and `bar.github.io` collapse to the same family, and a +// replace-mode import for one would clear the other. tldts defaults this off; cookie scoping needs +// it on. +const PUBLIC_SUFFIX_OPTIONS = { allowPrivateDomains: true } as const + +// psl exposed a single `listed` flag; tldts splits the same question across the two list sections. +function isListedSuffix(parsed: { isIcann: boolean | null; isPrivate: boolean | null }): boolean { + return parsed.isIcann === true || parsed.isPrivate === true +} + // Why (STA-4300): one definition of "family" for every consumer of the partition skip set — the // planner, the per-coordinate removal filter, and the path A domain comparison. Deriving it inline // in several places is what let the removal scope and the write set disagree (STA-4090, STA-4170). // // The IP test MUST run on normalizeCookieDomain's output, never the raw string: Chromium accepts -// many spellings of one address and psl mangles all of them (psl.parse('2130706433').domain is -// null, psl.parse('127.0.0.1').domain is '0.1'). normalizeCookieDomain runs the value through +// many spellings of one address and the suffix parser mangles all of them (tldts.parse('2130706433') +// .domain is null, tldts.parse('127.1').domain is '127.1'). normalizeCookieDomain runs the value through // `new URL()`, which canonicalises 127.1 / 2130706433 / 0x7f.1 / 010.0.0.1 / a trailing dot to a // dotted quad first, so isIP() then recognises every one of them. // @@ -65,12 +76,12 @@ export function registrableFamily(domain: string): string | null { if (host.startsWith('[') && host.endsWith(']') && isIP(host.slice(1, -1)) === 6) { return host } - const parsed = parseDomain(host) - if ('error' in parsed) { + const parsed = parseDomain(host, PUBLIC_SUFFIX_OPTIONS) + if (parsed.hostname === null) { return host } if (parsed.domain === null) { - return parsed.listed ? null : host + return isListedSuffix(parsed) ? null : host } return parsed.domain } @@ -80,11 +91,11 @@ export function normalizeCookieImportDomain(domain: string): string | null { if (!normalized) { return null } - const parsed = parseDomain(normalized) - if ('error' in parsed) { + const parsed = parseDomain(normalized, PUBLIC_SUFFIX_OPTIONS) + if (parsed.hostname === null) { return normalized.startsWith('[') && normalized.endsWith(']') ? normalized : null } - if (parsed.domain === null && parsed.listed) { + if (parsed.domain === null && isListedSuffix(parsed)) { return null } return normalized @@ -129,8 +140,8 @@ function domainSuffixes(domain: string): string[] { } function importDomainAncestors(domain: string): string[] { - const parsed = parseDomain(domain) - const boundary = 'error' in parsed ? domain : (parsed.domain ?? domain) + const parsed = parseDomain(domain, PUBLIC_SUFFIX_OPTIONS) + const boundary = parsed.hostname === null ? domain : (parsed.domain ?? domain) const ancestors: string[] = [] for (const suffix of domainSuffixes(domain)) { ancestors.push(suffix) diff --git a/src/main/browser/browser-cookie-public-suffix-scope.test.ts b/src/main/browser/browser-cookie-public-suffix-scope.test.ts new file mode 100644 index 00000000000..8d3fbce62da --- /dev/null +++ b/src/main/browser/browser-cookie-public-suffix-scope.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { + domainIsInImportedScope, + importedDomainScope, + normalizeCookieImportDomain, + registrableFamily +} from './browser-cookie-import-policy' + +// Why this file exists: the public-suffix engine decides which cookies share a removal scope, so a +// library swap silently re-partitions the jar. These cases pin the boundaries that moved (or had to +// be held) when this moved off `psl`. +describe('registrable family across public-suffix sections', () => { + it('keeps each PRIVATE-section tenant in its own family', () => { + // psl and tldts disagree here unless allowPrivateDomains is set; without it every + // *.github.io tenant collapses into one family and a replace-mode import clears siblings. + expect(registrableFamily('foo.github.io')).toBe('foo.github.io') + expect(registrableFamily('bar.github.io')).toBe('bar.github.io') + expect(registrableFamily('bar.s3.amazonaws.com')).toBe('bar.s3.amazonaws.com') + expect(registrableFamily('foo.vercel.app')).toBe('foo.vercel.app') + }) + + it('refuses to name a bare public suffix as a family', () => { + expect(registrableFamily('com')).toBeNull() + expect(registrableFamily('co.uk')).toBeNull() + expect(registrableFamily('github.io')).toBeNull() + // Absent from psl 1.15.0's 2024 snapshot; naming it a family would preserve a whole suffix. + expect(registrableFamily('api.br')).toBeNull() + expect(registrableFamily('seg.ar')).toBeNull() + }) + + it('resolves ICANN suffixes to the registrable domain', () => { + expect(registrableFamily('a.b.example.co.uk')).toBe('example.co.uk') + expect(registrableFamily('www.example.com')).toBe('example.com') + expect(registrableFamily('foo.example.api.br')).toBe('example.api.br') + }) + + it('returns the canonicalised address for every IP spelling', () => { + expect(registrableFamily('127.0.0.1')).toBe('127.0.0.1') + expect(registrableFamily('127.1')).toBe('127.0.0.1') + expect(registrableFamily('2130706433')).toBe('127.0.0.1') + expect(registrableFamily('[::1]')).toBe('[::1]') + }) + + it('treats an unlisted suffix as its own boundary', () => { + expect(registrableFamily('example.notaruleatall')).toBe('example.notaruleatall') + }) + + it('rejects a bare suffix as an import domain but keeps real hosts', () => { + expect(normalizeCookieImportDomain('co.uk')).toBeNull() + expect(normalizeCookieImportDomain('api.br')).toBeNull() + expect(normalizeCookieImportDomain('.example.com')).toBe('example.com') + expect(normalizeCookieImportDomain('foo.github.io')).toBe('foo.github.io') + }) +}) + +// Why: `.local` is absent from the PSL, and the two libraries disagreed about what that means. psl +// returned an all-null parse, so every `*.orca.local` host was its own family; tldts applies the +// default single-label rule and stops at `orca.local`, which is what Chromium treats as registrable. +// The widening is deliberate, so it is pinned here rather than left to the next library bump. +describe('unlisted .local suffix', () => { + it('stops at the two-label boundary', () => { + expect(registrableFamily('app.orca.local')).toBe('orca.local') + expect(registrableFamily('orca.local')).toBe('orca.local') + }) + + // The consequence of the boundary move: a replace-mode import of one host now also clears + // non-host-only cookies scoped to `.orca.local`, which every sibling `*.orca.local` host shares. + it('pulls the shared parent into the removal scope', () => { + const scope = importedDomainScope(['app.orca.local']) + + expect(domainIsInImportedScope(scope, 'orca.local', false)).toBe(true) + expect(domainIsInImportedScope(scope, 'orca.local', true)).toBe(false) + }) +}) + +// Why: psl's 2024 snapshot carried `compute.amazonaws.com` as a literal PRIVATE suffix; the current +// list only has the `*.compute.amazonaws.com` wildcard, so the bare host is an ordinary ICANN domain +// now. That moves a real host shape from "no family" to `amazonaws.com`. +describe('suffix entries that changed shape upstream', () => { + it('reads bare compute.amazonaws.com as a registrable domain', () => { + expect(registrableFamily('compute.amazonaws.com')).toBe('amazonaws.com') + expect(normalizeCookieImportDomain('compute.amazonaws.com')).toBe('compute.amazonaws.com') + }) + + it('still refuses the wildcard child and the sibling private suffix', () => { + expect(registrableFamily('foo.compute.amazonaws.com')).toBeNull() + expect(registrableFamily('s3.amazonaws.com')).toBeNull() + }) +}) diff --git a/src/main/browser/browser-cookie-registrable-family.test.ts b/src/main/browser/browser-cookie-registrable-family.test.ts index 00244ce02d9..c1aadc59654 100644 --- a/src/main/browser/browser-cookie-registrable-family.test.ts +++ b/src/main/browser/browser-cookie-registrable-family.test.ts @@ -21,9 +21,10 @@ describe('registrableFamily', () => { expect(registrableFamily(host)).toBe(expected) }) - // Why: psl treats an IPv4 literal as a dotted DNS name — psl.parse('127.0.0.1').domain is '0.1'. - // These pass only because the IP check runs on normalizeCookieDomain's canonicalised output. - // Moving the check before normalisation reintroduces a wrong, destructive family. + // Why: the suffix parser reads a non-dotted-quad IPv4 spelling as a DNS name — + // tldts.parse('127.1').domain is '127.1' and tldts.parse('2130706433').domain is null. These pass + // only because the IP check runs on normalizeCookieDomain's canonicalised output. Moving the check + // before normalisation reintroduces a wrong, destructive family. it.each([ ['127.0.0.1', '127.0.0.1'], ['192.168.1.1', '192.168.1.1'], @@ -32,15 +33,15 @@ describe('registrableFamily', () => { ['2130706433', '127.0.0.1'], ['0x7f.1', '127.0.0.1'], ['127.0.0.1.', '127.0.0.1'], - // Octal, and 8.0.0.1 is the correct reading — psl would have produced '0.1'. + // Octal, and 8.0.0.1 is the correct reading — unnormalised, this parses as a DNS name. ['010.0.0.1', '8.0.0.1'] ])('recognises the IPv4 literal %s as %s', (host, expected) => { expect(registrableFamily(host)).toBe(expected) }) // Why: isIP('[::1]') is 0, so the bracketed form needs its own branch. Without it these fall - // through to psl, which throws, which happens to return the host — right answer, wrong reason, - // and it stops being right the moment the error branch is touched. + // through to the parser, which strips the brackets and reports no suffix — the unlisted path then + // happens to return the host. Right answer, wrong reason, and only while that path is untouched. it.each([ ['[::1]', '[::1]'], ['[2001:db8::1]', '[2001:db8::1]'] diff --git a/src/main/claude-accounts/keychain-config-directory-aliases.test.ts b/src/main/claude-accounts/keychain-config-directory-aliases.test.ts new file mode 100644 index 00000000000..9f6b1ca0cdf --- /dev/null +++ b/src/main/claude-accounts/keychain-config-directory-aliases.test.ts @@ -0,0 +1,83 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, sep } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { claudeConfigDirKeychainAliases } from './keychain' + +let directory: string +let canonical: string +let linked: string + +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'orca-claude-keychain-alias-')) + canonical = join(directory, 'canonical') + linked = join(directory, 'linked') + mkdirSync(canonical) + symlinkSync(canonical, linked, process.platform === 'win32' ? 'junction' : 'dir') + canonical = realpathSync(canonical) +}) + +afterEach(() => { + rmSync(directory, { recursive: true, force: true }) +}) + +describe('Claude config directory Keychain aliases', () => { + it.each([{ segments: ['.claude'] }, { segments: ['removed', '.claude'] }])( + 'resolves a missing config below a symlinked ancestor without creating it ($segments)', + ({ segments }) => { + const configDir = join(linked, ...segments) + expect(claudeConfigDirKeychainAliases(configDir)).toEqual([ + configDir, + join(canonical, ...segments) + ]) + expect(existsSync(configDir)).toBe(false) + expect(existsSync(join(linked, segments[0]))).toBe(false) + } + ) + + it('retains the canonical alias for an existing directory', () => { + const configDir = join(linked, '.claude') + mkdirSync(configDir) + expect(claudeConfigDirKeychainAliases(configDir)).toEqual([ + configDir, + join(canonical, '.claude') + ]) + }) + + it('keeps a missing path with parent traversal raw instead of guessing through a symlink', () => { + const child = join(canonical, 'child') + const childLink = join(directory, 'child-link') + mkdirSync(child) + symlinkSync(child, childLink, process.platform === 'win32' ? 'junction' : 'dir') + const configDir = [childLink, '..', '.claude'].join(sep) + + expect(claudeConfigDirKeychainAliases(configDir)).toEqual([configDir]) + expect(existsSync(join(canonical, '.claude'))).toBe(false) + }) + + it('does not duplicate an already canonical missing path', () => { + const configDir = join(canonical, '.claude') + expect(claudeConfigDirKeychainAliases(configDir)).toEqual([configDir]) + }) + + it.skipIf(process.platform === 'win32')('does not invent an alias for a broken symlink', () => { + const configDir = join(linked, '.claude') + symlinkSync(join(directory, 'missing-target'), configDir, 'dir') + expect(claudeConfigDirKeychainAliases(configDir)).toEqual([configDir]) + }) + + it('does not invent an alias below a file', () => { + const file = join(linked, 'file') + writeFileSync(file, '') + const configDir = join(file, '.claude') + expect(claudeConfigDirKeychainAliases(configDir)).toEqual([configDir]) + }) +}) diff --git a/src/main/claude-accounts/keychain.test.ts b/src/main/claude-accounts/keychain.test.ts index 5a3321200f7..3ee2c6b7759 100644 --- a/src/main/claude-accounts/keychain.test.ts +++ b/src/main/claude-accounts/keychain.test.ts @@ -1,5 +1,8 @@ import { createHash } from 'node:crypto' import { execFile } from 'node:child_process' +import { mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { deleteActiveClaudeKeychainCredentials, @@ -19,6 +22,7 @@ const originalUser = process.env.USER const originalUsername = process.env.USERNAME const TEST_USER = 'orca-test-user' const SSO_USER = 'sso.user@example.com' +let configDir: string function setPlatform(platform: NodeJS.Platform): void { Object.defineProperty(process, 'platform', { @@ -44,6 +48,7 @@ function invokeExecFileCallback( describe('Claude Keychain credentials', () => { beforeEach(() => { + configDir = realpathSync(mkdtempSync(join(tmpdir(), 'orca-claude-keychain-'))) setPlatform('darwin') execFileMock.mockReset() process.env.USER = TEST_USER @@ -51,6 +56,7 @@ describe('Claude Keychain credentials', () => { }) afterEach(() => { + rmSync(configDir, { recursive: true, force: true }) vi.useRealTimers() if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -68,7 +74,6 @@ describe('Claude Keychain credentials', () => { }) it('reads config-scoped Claude Code 2.1 credentials before legacy credentials', async () => { - const configDir = '/tmp/orca-claude-login-test' const scopedService = serviceForConfigDir(configDir) execFileMock.mockImplementationOnce((_file, _args, _options, callback) => { invokeExecFileCallback(callback, null, '{"claudeAiOauth":{"accessToken":"scoped"}}\n', '') @@ -91,7 +96,6 @@ describe('Claude Keychain credentials', () => { }) it('falls back to the legacy unsuffixed Claude Code credentials service', async () => { - const configDir = '/tmp/orca-claude-login-test' const notFound = Object.assign(new Error('not found'), { code: 44 }) execFileMock .mockImplementationOnce((_file, _args, _options, callback) => { @@ -116,7 +120,6 @@ describe('Claude Keychain credentials', () => { }) it('writes active credentials to the config-scoped Claude Code service', async () => { - const configDir = '/tmp/orca-claude-login-test' const scopedService = serviceForConfigDir(configDir) execFileMock.mockImplementationOnce((_file, _args, _options, callback) => { invokeExecFileCallback(callback, null, '', '') @@ -138,7 +141,6 @@ describe('Claude Keychain credentials', () => { }) it('writes runtime credentials to scoped and legacy services for old Claude Code compatibility', async () => { - const configDir = '/tmp/orca-claude-login-test' const scopedService = serviceForConfigDir(configDir) execFileMock.mockImplementation((_file, _args, _options, callback) => { invokeExecFileCallback(callback, null, '', '') @@ -172,7 +174,6 @@ describe('Claude Keychain credentials', () => { }) it('strictly reads only the requested active credentials service', async () => { - const configDir = '/tmp/orca-claude-login-test' const scopedService = serviceForConfigDir(configDir) execFileMock.mockImplementationOnce((_file, _args, _options, callback) => { invokeExecFileCallback(callback, null, 'scoped\n', '') @@ -194,7 +195,6 @@ describe('Claude Keychain credentials', () => { it('rejects when a keychain read never reports completion', async () => { vi.useFakeTimers() - const configDir = '/tmp/orca-claude-login-test' const killMock = vi.fn() execFileMock.mockImplementationOnce(() => ({ kill: killMock }) as never) @@ -223,7 +223,6 @@ describe('Claude Keychain credentials', () => { }) it('deletes both scoped and legacy active credentials for config-dir cleanup', async () => { - const configDir = '/tmp/orca-claude-login-test' const scopedService = serviceForConfigDir(configDir) execFileMock.mockImplementation((_file, _args, _options, callback) => { invokeExecFileCallback(callback, null, '', '') @@ -260,7 +259,6 @@ describe('Claude Keychain credentials', () => { it('cleans both Claude Code and raw $USER Keychain accounts after a failed SSO login', async () => { process.env.USER = SSO_USER - const configDir = '/tmp/orca-claude-login-test' const scopedService = serviceForConfigDir(configDir) execFileMock.mockImplementation((_file, _args, _options, callback) => { invokeExecFileCallback(callback, null, '', '') diff --git a/src/main/claude-accounts/keychain.ts b/src/main/claude-accounts/keychain.ts index 92c11e74650..eca0e9af44d 100644 --- a/src/main/claude-accounts/keychain.ts +++ b/src/main/claude-accounts/keychain.ts @@ -1,7 +1,8 @@ import { execFile } from 'node:child_process' import { createHash } from 'node:crypto' -import { realpathSync } from 'node:fs' +import { lstatSync, realpathSync } from 'node:fs' import { userInfo } from 'node:os' +import { basename, dirname, join } from 'node:path' const ACTIVE_CLAUDE_SERVICE = 'Claude Code-credentials' const ORCA_CLAUDE_SERVICE = 'Orca Claude Code Managed Credentials' @@ -131,13 +132,46 @@ function getActiveClaudeService(configDir?: string): string { export function claudeConfigDirKeychainAliases(configDir: string): string[] { const aliases = [configDir] - try { - const canonical = realpathSync(configDir) - if (canonical !== configDir) { - aliases.push(canonical) + const missingSegments: string[] = [] + let existingPath = configDir + while (true) { + try { + const canonical = join(realpathSync(existingPath), ...missingSegments) + if (canonical !== configDir) { + aliases.push(canonical) + } + break + } catch (error) { + // Missing paths with parent traversal cannot prove an alias across symlinks. + if ( + !(error instanceof Error) || + !('code' in error) || + error.code !== 'ENOENT' || + configDir.split(/[\\/]/).includes('..') + ) { + break + } + try { + // A broken symlink has no known canonical target; do not guess its alias. + lstatSync(existingPath) + break + } catch (missingError) { + if ( + !(missingError instanceof Error) || + !('code' in missingError) || + missingError.code !== 'ENOENT' + ) { + break + } + } + const parent = dirname(existingPath) + if (parent === existingPath) { + break + } + // Preserve canonical Keychain lookup without recreating a removed config directory. + missingSegments.unshift(basename(existingPath)) + existingPath = parent } - } catch { - // Login temp dirs can vanish before capture; keep the raw path. } return aliases } diff --git a/src/main/claude-accounts/runtime-auth-path-materialization.test.ts b/src/main/claude-accounts/runtime-auth-path-materialization.test.ts new file mode 100644 index 00000000000..dbccd87cee5 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-path-materialization.test.ts @@ -0,0 +1,58 @@ +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../shared/constants' + +const testState = { + fakeHomeDir: '', + previousConfigDir: undefined as string | undefined +} + +vi.mock('electron', () => ({ app: { getPath: () => testState.fakeHomeDir } })) + +vi.mock('node:os', async () => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + const actual = await vi.importActual('node:os') + return { ...actual, homedir: () => testState.fakeHomeDir } +}) + +const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + +beforeEach(() => { + testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-claude-rate-limit-path-')) + testState.previousConfigDir = process.env.CLAUDE_CONFIG_DIR + delete process.env.CLAUDE_CONFIG_DIR +}) + +afterEach(() => { + rmSync(testState.fakeHomeDir, { recursive: true, force: true }) + if (testState.previousConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = testState.previousConfigDir + } + testState.fakeHomeDir = '' +}) + +describe('Claude runtime auth path materialization', () => { + it('does not create the config directory while preparing a background usage fetch', async () => { + const settings = { + ...getDefaultSettings(testState.fakeHomeDir), + disabledTuiAgents: ['claude'] as const, + claudeManagedAccounts: [], + activeClaudeManagedAccountId: null + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn() + } + const service = new ClaudeRuntimeAuthService(store as never) + + const preparation = await service.prepareForRateLimitFetch() + + expect(preparation.configDir).toBe(join(testState.fakeHomeDir, '.claude')) + expect(preparation.provenance).toBe('system') + expect(existsSync(preparation.configDir)).toBe(false) + }) +}) diff --git a/src/main/claude-accounts/runtime-auth-service-materialization.test.ts b/src/main/claude-accounts/runtime-auth-service-materialization.test.ts index f42a938a02f..2b5c96cf136 100644 --- a/src/main/claude-accounts/runtime-auth-service-materialization.test.ts +++ b/src/main/claude-accounts/runtime-auth-service-materialization.test.ts @@ -53,8 +53,9 @@ describe('ClaudeRuntimeAuthService', () => { cleanupRuntimeAuthTestState() }) - it('rematerializes unchanged managed credentials when the runtime file is missing', async () => { + it('creates and recreates the runtime directory when materializing managed credentials', async () => { const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + rmSync(expectedRuntimeConfigDir(), { recursive: true, force: true }) const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') const managedAuthPath = createManagedClaudeAuth( testState.userDataDir, @@ -73,7 +74,7 @@ describe('ClaudeRuntimeAuthService', () => { expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) - rmSync(runtimeCredentialsPath, { force: true }) + rmSync(expectedRuntimeConfigDir(), { recursive: true, force: true }) await service.prepareForClaudeLaunch() expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) diff --git a/src/main/claude-accounts/runtime-paths.test.ts b/src/main/claude-accounts/runtime-paths.test.ts new file mode 100644 index 00000000000..cd1607e5a31 --- /dev/null +++ b/src/main/claude-accounts/runtime-paths.test.ts @@ -0,0 +1,113 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import type * as NodeFs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const testState = { + fakeHomeDir: '', + previousConfigDir: undefined as string | undefined +} + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, mkdirSync: vi.fn(actual.mkdirSync) } +}) + +vi.mock('node:os', async () => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + const actual = await vi.importActual('node:os') + return { + ...actual, + homedir: () => testState.fakeHomeDir + } +}) + +const { ClaudeRuntimePathResolver } = await import('./runtime-paths') + +beforeEach(() => { + testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-claude-runtime-paths-')) + testState.previousConfigDir = process.env.CLAUDE_CONFIG_DIR + delete process.env.CLAUDE_CONFIG_DIR +}) + +afterEach(() => { + rmSync(testState.fakeHomeDir, { recursive: true, force: true }) + if (testState.previousConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = testState.previousConfigDir + } + testState.fakeHomeDir = '' +}) + +describe('ClaudeRuntimePathResolver', () => { + it.each([false, true])( + 'does no mkdir work for repeated reads (directory exists: %s)', + (exists) => { + if (exists) { + mkdirSync(join(testState.fakeHomeDir, '.claude'), { recursive: true }) + } + vi.mocked(mkdirSync).mockClear() + const resolver = new ClaudeRuntimePathResolver() + + for (let index = 0; index < 1000; index += 1) { + resolver.getRuntimePaths() + } + + expect(mkdirSync).not.toHaveBeenCalled() + } + ) + + it('leaves the default config directory alone while resolving paths', () => { + const paths = new ClaudeRuntimePathResolver().getRuntimePaths() + + expect(paths.configDir).toBe(join(testState.fakeHomeDir, '.claude')) + // Why: background rate-limit refreshes resolve these paths even when Claude + // is disabled, so resolving must never materialize the directory (#12181). + expect(existsSync(paths.configDir)).toBe(false) + }) + + it('leaves an inherited CLAUDE_CONFIG_DIR alone while resolving paths', () => { + const inherited = join(testState.fakeHomeDir, 'inherited-claude') + process.env.CLAUDE_CONFIG_DIR = inherited + + const paths = new ClaudeRuntimePathResolver().getRuntimePaths() + + expect(paths.configDir).toBe(inherited) + expect(existsSync(inherited)).toBe(false) + expect(paths.envPatch).toEqual({ CLAUDE_CONFIG_DIR: inherited }) + }) + + it('resolves credentials next to the config directory', () => { + const paths = new ClaudeRuntimePathResolver().getRuntimePaths() + + expect(paths.credentialsPath).toBe(join(testState.fakeHomeDir, '.claude', '.credentials.json')) + }) + + it('falls back to the home config file when no colocated config exists', () => { + const paths = new ClaudeRuntimePathResolver().getRuntimePaths() + + expect(paths.configPath).toBe(join(testState.fakeHomeDir, '.claude.json')) + expect(paths.envPatch).toEqual({}) + }) + + it('prefers a colocated config file once it exists', () => { + const configDir = join(testState.fakeHomeDir, '.claude') + mkdirSync(configDir, { recursive: true }) + writeFileSync(join(configDir, '.claude.json'), '{}') + + const paths = new ClaudeRuntimePathResolver().getRuntimePaths() + + expect(paths.configPath).toBe(join(configDir, '.claude.json')) + }) + + it('keeps the inherited config file colocated even before it exists', () => { + const inherited = join(testState.fakeHomeDir, 'inherited-claude') + process.env.CLAUDE_CONFIG_DIR = inherited + + const paths = new ClaudeRuntimePathResolver().getRuntimePaths() + + expect(paths.configPath).toBe(join(inherited, '.claude.json')) + }) +}) diff --git a/src/main/claude-accounts/runtime-paths.ts b/src/main/claude-accounts/runtime-paths.ts index 350cdd08372..d9ff5701f26 100644 --- a/src/main/claude-accounts/runtime-paths.ts +++ b/src/main/claude-accounts/runtime-paths.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync } from 'node:fs' +import { existsSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' import type { ClaudeEnvPatch } from './environment' @@ -13,8 +13,8 @@ export type ClaudeRuntimePaths = { export class ClaudeRuntimePathResolver { getRuntimePaths(): ClaudeRuntimePaths { const inheritedConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || null + // Why: disabled Claude still reaches this resolver through background usage refreshes. const configDir = inheritedConfigDir || join(homedir(), '.claude') - mkdirSync(configDir, { recursive: true }) return { configDir, diff --git a/src/main/claude/claude-structured-model-preflight.test.ts b/src/main/claude/claude-structured-model-preflight.test.ts new file mode 100644 index 00000000000..e4f5d00b196 --- /dev/null +++ b/src/main/claude/claude-structured-model-preflight.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' +import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' +import { + restoreClaudeStructuredSessionOptions, + setClaudeStructuredOption +} from './claude-structured-options' +import type { ClaudeSession } from './claude-structured-session-state' + +/** Verbatim row shapes from Claude Code 2.1.260's list_models response. */ +const DEFAULT_ROW = { value: 'default', resolvedModel: 'claude-opus-5', displayName: 'Default' } +const SONNET = { value: 'sonnet', resolvedModel: 'claude-sonnet-5', displayName: 'Sonnet' } +const HAIKU = { + value: 'haiku', + resolvedModel: 'claude-haiku-4-5-20251001', + displayName: 'Haiku' +} + +function sessionWith(catalog: readonly Record[] | 'unavailable') { + const calls: string[] = [] + return { + session: { + options: new Map(), + reportedOptions: {} as { model?: string; effort?: string }, + optionMutationSequence: 0, + reportedModelMutation: 0, + confirmedOptions: new Set(), + restoreSkippedOptions: new Set(), + connection: { + supportedModels: async () => { + calls.push('list_models') + if (catalog === 'unavailable') { + throw new Error('this CLI predates list_models') + } + return [...catalog] + }, + setModel: async (model: string) => { + calls.push(`set_model:${model}`) + } + } + } as unknown as ClaudeSession, + calls + } +} + +describe('Claude model pre-flight against the catalog the CLI listed', () => { + it('refuses a model the provider does not list', async () => { + const { session, calls } = sessionWith([DEFAULT_ROW, SONNET, HAIKU]) + + await expect( + setClaudeStructuredOption(session, { key: 'model', value: 'not-a-real-model-xyz' }, undefined) + ).rejects.toBeInstanceOf(AgentSessionOptionRejectedError) + // Measured on Claude Code 2.1.260: set_model resolves for an unlisted id and + // every later turn returns is_error with zero tokens. Nothing undoes the + // write, so the refusal has to land before it. + expect(calls).toEqual(['list_models']) + expect(session.options.has('model')).toBe(false) + }) + + it('refuses an unlisted model replayed by restore, and skips it', async () => { + // Needs no user error: a model valid when it was persisted can be retired. + const { session, calls } = sessionWith([DEFAULT_ROW, SONNET]) + session.options.set('model', 'claude-opus-4-retired') + + await restoreClaudeStructuredSessionOptions(session, undefined) + + expect(calls).toEqual(['list_models']) + expect(session.options.has('model')).toBe(false) + expect([...session.restoreSkippedOptions]).toEqual(['model']) + }) + + it('applies a model the provider lists', async () => { + const { session, calls } = sessionWith([DEFAULT_ROW, SONNET, HAIKU]) + + await expect( + setClaudeStructuredOption(session, { key: 'model', value: 'haiku' }, undefined) + ).resolves.toEqual({ model: 'haiku' }) + expect(calls).toEqual(['list_models', 'set_model:haiku']) + }) + + it('applies a resolved model id the catalog carries only under its alias', async () => { + const { session, calls } = sessionWith([DEFAULT_ROW, SONNET]) + + await expect( + setClaudeStructuredOption(session, { key: 'model', value: 'claude-sonnet-5' }, undefined) + ).resolves.toEqual({ model: 'claude-sonnet-5' }) + expect(calls).toEqual(['list_models', 'set_model:claude-sonnet-5']) + }) + + it('refuses nothing when list_models is unavailable', async () => { + // A CLI predating list_models would otherwise have every model refused, and + // restore swallows the rejection, so the user's pick would vanish silently. + const { session, calls } = sessionWith('unavailable') + + await expect( + setClaudeStructuredOption(session, { key: 'model', value: 'sonnet' }, undefined) + ).resolves.toEqual({ model: 'sonnet' }) + expect(calls).toEqual(['list_models', 'set_model:sonnet']) + }) + + it('refuses nothing when the listed catalog is empty', async () => { + // An empty answer identifies no model, so it is not evidence against one. + const { session, calls } = sessionWith([]) + + await expect( + setClaudeStructuredOption(session, { key: 'model', value: 'sonnet' }, undefined) + ).resolves.toEqual({ model: 'sonnet' }) + expect(calls).toEqual(['list_models', 'set_model:sonnet']) + }) + + it('refuses nothing when the catalog carries only the synthetic default row', async () => { + // listedModels drops that row, leaving a list that identifies no model. + const { session, calls } = sessionWith([DEFAULT_ROW]) + + await expect( + setClaudeStructuredOption(session, { key: 'model', value: 'sonnet' }, undefined) + ).resolves.toEqual({ model: 'sonnet' }) + expect(calls).toEqual(['list_models', 'set_model:sonnet']) + }) + + it('leaves a restored model the provider lists in place', async () => { + const { session, calls } = sessionWith([DEFAULT_ROW, SONNET]) + session.options.set('model', 'sonnet') + + await restoreClaudeStructuredSessionOptions(session, undefined) + + expect(calls).toEqual(['list_models', 'set_model:sonnet']) + expect(session.options.get('model')).toBe('sonnet') + expect([...session.restoreSkippedOptions]).toEqual([]) + }) +}) diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts index 2375df12d93..1bfae10b592 100644 --- a/src/main/claude/claude-structured-options.test.ts +++ b/src/main/claude/claude-structured-options.test.ts @@ -6,7 +6,12 @@ import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSession { return { - connection: { setModel } as ClaudeSession['connection'], + // An empty catalog identifies no model, so the pre-flight refuses nothing and + // this stays a test about fencing. + connection: { + setModel, + supportedModels: async (): Promise => [] + } as ClaudeSession['connection'], providerSessionId: 'provider-session', claudeConfigDir: '/accounts/claude', leafUuid: null, diff --git a/src/main/claude/claude-structured-options.ts b/src/main/claude/claude-structured-options.ts index 3d1377b12c6..7f607755563 100644 --- a/src/main/claude/claude-structured-options.ts +++ b/src/main/claude/claude-structured-options.ts @@ -5,6 +5,7 @@ import { isAgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' import { + claudeCatalogAdmitsModel, readClaudeCurrentModel, readClaudeModelEffortLevels, readClaudeSettingsEffort @@ -66,6 +67,13 @@ export async function setClaudeStructuredOption( ) } } + // set_model resolves for a model the provider never lists and the session then + // fails every turn with zero tokens, so the acceptance proves nothing and only + // the catalog does. Restore replays a pick the provider may since have retired, + // which reaches here with no user error at all. + if (input.key === 'model' && !(await claudeCatalogAdmitsModel(session, input.value, timeoutMs))) { + throw new AgentSessionOptionRejectedError(`claude does not list a model named ${input.value}`) + } const modelWasConfirmed = readClaudeCurrentModel(session).confirmed const mutationSequence = ++session.optionMutationSequence // Only a model write can stale the model report — an effort or permission-mode diff --git a/src/main/claude/claude-structured-session-adapter-turns.test.ts b/src/main/claude/claude-structured-session-adapter-turns.test.ts index fc5eaa6b66e..00248b09f12 100644 --- a/src/main/claude/claude-structured-session-adapter-turns.test.ts +++ b/src/main/claude/claude-structured-session-adapter-turns.test.ts @@ -80,8 +80,11 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => { await expect( adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'sonnet', fence: 7 }) ).resolves.toEqual({ model: 'sonnet' }) - expect(claude.connections[0].calls.slice(-2)).toEqual([ + // The model write pre-flights the catalog first; this CLI lists nothing, which + // identifies no model and so refuses none. + expect(claude.connections[0].calls.slice(-3)).toEqual([ { subtype: 'interrupt', params: {} }, + { subtype: 'list_models' }, { subtype: 'set_model', params: { model: 'sonnet' } } ]) diff --git a/src/main/claude/claude-structured-session-options.ts b/src/main/claude/claude-structured-session-options.ts index afb4fd65076..2385f362c2a 100644 --- a/src/main/claude/claude-structured-session-options.ts +++ b/src/main/claude/claude-structured-session-options.ts @@ -149,6 +149,28 @@ export async function readClaudeModelEffortLevels( } } +/** + * Whether the catalog admits the model, matched by alias or resolved id so a pick + * stored as either one is found. The permissive case lives here rather than at the + * call site: every caller must treat an unidentified catalog the same way, and one + * that forgot would refuse every model on a CLI that cannot answer. + */ +export async function claudeCatalogAdmitsModel( + session: ClaudeSession, + modelId: string, + timeoutMs: number | undefined +): Promise { + const catalog = await session.connection.supportedModels({ timeoutMs }).catch(() => null) + const models = listedModels(catalog ? { models: catalog } : null) + // An empty list identifies no model, so it is not evidence against one — a live + // CLI predating `list_models` would otherwise have every model refused under it. + // Do not turn this into a refusal. + return ( + models.length === 0 || + models.some((model) => model.id === modelId || model.resolvedModel === modelId) + ) +} + export async function readClaudeStructuredSessionOptions( session: ClaudeSession, timeoutMs: number | undefined diff --git a/src/main/daemon/daemon-stream-data-batcher.ts b/src/main/daemon/daemon-stream-data-batcher.ts index 53e17002179..9b5a93c4f00 100644 --- a/src/main/daemon/daemon-stream-data-batcher.ts +++ b/src/main/daemon/daemon-stream-data-batcher.ts @@ -86,7 +86,7 @@ export class DaemonStreamDataBatcher { if ( options.flushImmediately === true && - this.queuedCharsForSession(batch, sessionId) <= + this.queuedCharsForSession(batch, sessionId, options.flushMaxChars) <= (options.flushMaxChars ?? Number.POSITIVE_INFINITY) ) { this.flushSession(clientId, sessionId) @@ -249,11 +249,18 @@ export class DaemonStreamDataBatcher { }) } - private queuedCharsForSession(batch: PendingStreamDataBatch, sessionId: string): number { + private queuedCharsForSession( + batch: PendingStreamDataBatch, + sessionId: string, + stopAfter = Number.POSITIVE_INFINITY + ): number { let chars = 0 for (const entry of batch.queue) { if (entry.sessionId === sessionId) { chars += entry.data.length + if (chars > stopAfter) { + return chars + } } } return chars diff --git a/src/main/runtime/leaf-pty-verdict-expiry.test.ts b/src/main/runtime/leaf-pty-verdict-expiry.test.ts new file mode 100644 index 00000000000..370532c2459 --- /dev/null +++ b/src/main/runtime/leaf-pty-verdict-expiry.test.ts @@ -0,0 +1,136 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { PROVEN_ABSENT_LEAF_PTY_TTL_MS as TTL_MS } from './orca-runtime-core' + +type VerdictInternals = { + provenAbsentLeafPtyVerdicts: Map + isLeafPtyProvenAbsent: (ptyId: string) => Promise +} + +function createRuntime( + probePtyLiveness = vi.fn<(ptyId: string) => Promise>(async () => false) +) { + const runtime = new OrcaRuntimeService() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + hasPty: (id) => id === 'live', + probePtyLiveness + }) + const internals = runtime as unknown as VerdictInternals + return { + runtime, + probe: probePtyLiveness, + verdicts: internals.provenAbsentLeafPtyVerdicts, + isAbsent: (id: string) => internals.isLeafPtyProvenAbsent(id) + } +} + +afterEach(() => vi.restoreAllMocks()) + +describe('leaf PTY verdict expiry', () => { + it('retires old unique IDs on a live-PTY consult without probing that live PTY', async () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(100_000) + const { verdicts, isAbsent, probe } = createRuntime() + for (let index = 0; index < 1_000; index++) { + await expect(isAbsent(`retired-${index}`)).resolves.toBe(true) + } + expect(verdicts.size).toBe(1_000) + now.mockReturnValue(100_000 + TTL_MS) + + await expect(isAbsent('live')).resolves.toBe(false) + + expect(verdicts.size).toBe(0) + expect(probe).toHaveBeenCalledTimes(1_000) + }) + + it('preserves every fresh verdict and the exact per-key TTL between bulk sweeps', async () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(100_000) + const { verdicts, isAbsent, probe } = createRuntime() + await isAbsent('initial') + now.mockReturnValue(101_000) + for (let index = 0; index < 1_000; index++) { + await isAbsent(`fresh-${index}`) + } + now.mockReturnValue(100_000 + TTL_MS) + await isAbsent('live') + expect(verdicts.size).toBe(1_000) + now.mockReturnValue(101_000 + TTL_MS - 1) + probe.mockClear() + for (let index = 0; index < 1_000; index++) { + await expect(isAbsent(`fresh-${index}`)).resolves.toBe(true) + } + expect(probe).not.toHaveBeenCalled() + now.mockReturnValue(101_000 + TTL_MS) + probe.mockResolvedValue(null) + + await expect(isAbsent('fresh-0')).resolves.toBe(false) + + expect(probe).toHaveBeenCalledOnce() + expect(verdicts.has('fresh-0')).toBe(false) + }) + + it('sweeps at most once per TTL through a burst of probes and live sends', async () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(100_000) + const { verdicts, isAbsent } = createRuntime() + const iterations = vi.spyOn(verdicts, Symbol.iterator) + for (let index = 0; index < 1_000; index++) { + await isAbsent(`dead-${index}`) + await isAbsent('live') + } + expect(iterations).toHaveBeenCalledOnce() + now.mockReturnValue(100_000 + TTL_MS) + for (let index = 0; index < 1_000; index++) { + await isAbsent('live') + } + expect(iterations).toHaveBeenCalledTimes(2) + expect(verdicts.size).toBe(0) + }) + + it('cleans old entries when a delayed probe completes after the next sweep is due', async () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(100_000) + const { verdicts, isAbsent, probe } = createRuntime() + await isAbsent('old') + let finish!: (value: boolean | null) => void + probe.mockImplementationOnce(() => new Promise((resolve) => (finish = resolve))) + const pending = isAbsent('new') + now.mockReturnValue(100_000 + 2 * TTL_MS) + finish(false) + + await expect(pending).resolves.toBe(true) + + expect([...verdicts]).toEqual([['new', 100_000 + 2 * TTL_MS]]) + }) + + it('resumes pruning after a backward clock adjustment without expiring future-dated evidence', async () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(100_000) + const { verdicts, isAbsent, probe } = createRuntime() + await isAbsent('future-dated') + now.mockReturnValue(1_000) + await isAbsent('after-clock-change') + now.mockReturnValue(1_000 + TTL_MS) + + await isAbsent('live') + + expect([...verdicts]).toEqual([['future-dated', 100_000]]) + await expect(isAbsent('future-dated')).resolves.toBe(true) + expect(probe).toHaveBeenCalledTimes(2) + }) + + it('leaves unverifiable probes uncached and preserves concurrent probe coalescing', async () => { + vi.spyOn(Date, 'now').mockReturnValue(100_000) + let finish!: (value: boolean | null) => void + const probe = vi.fn(() => new Promise((resolve) => (finish = resolve))) + const { verdicts, isAbsent } = createRuntime(probe) + const first = isAbsent('ssh-id') + const second = isAbsent('ssh-id') + expect(first).toBe(second) + finish(null) + await expect(first).resolves.toBe(false) + expect(verdicts.size).toBe(0) + probe.mockRejectedValueOnce(new Error('host unavailable')) + await expect(isAbsent('ssh-id')).resolves.toBe(false) + expect(verdicts.size).toBe(0) + }) +}) diff --git a/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts b/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts index 1b09f2f3189..6fd8e6ed2f1 100644 --- a/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts +++ b/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts @@ -1,6 +1,7 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithResolveTerminalPane } from './orca-runtime-resolve-terminal-pane' import { PROVEN_ABSENT_LEAF_PTY_TTL_MS } from './orca-runtime-core' +import { pruneExpiredProvenAbsentLeafPtyVerdicts } from './proven-absent-leaf-pty-verdicts' import type { RuntimeTerminalSend } from '../../shared/runtime-types' import type { RuntimeAgentPromptWriteOptions } from './runtime-terminal-contracts' import { @@ -10,6 +11,26 @@ import { import { buildAgentPromptPasteBytes } from '../../shared/agent-prompt-injection' export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithResolveTerminalPane { + private lastProvenAbsentLeafPtyVerdictPruneAt: number | undefined + + private pruneExpiredLeafPtyVerdicts(now: number): void { + const lastPruneAt = this.lastProvenAbsentLeafPtyVerdictPruneAt + // Per-key expiry stays exact; throttle whole-cache scans on the keystroke path. + if ( + lastPruneAt !== undefined && + now >= lastPruneAt && + now - lastPruneAt < PROVEN_ABSENT_LEAF_PTY_TTL_MS + ) { + return + } + this.lastProvenAbsentLeafPtyVerdictPruneAt = now + pruneExpiredProvenAbsentLeafPtyVerdicts( + this.provenAbsentLeafPtyVerdicts, + now, + PROVEN_ABSENT_LEAF_PTY_TTL_MS + ) + } + protected controllerKnowsPtyIsLive(ptyId: string): boolean { try { return this.ptyController?.hasPty?.(ptyId) === true @@ -21,6 +42,7 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso /** True only on controller-proven absence; live, unknown, and probe errors all answer false. */ protected isLeafPtyProvenAbsent(ptyId: string): Promise { + this.pruneExpiredLeafPtyVerdicts(Date.now()) // Why hasPty and not ptysById: graph sync mirrors a connected record for // every leaf ptyId — including a prior process's — so runtime records can't // distinguish live from stale. The controller's exact-id hasPty is the @@ -50,7 +72,9 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso if ((await probeLiveness(ptyId)) !== false) { return false } - this.provenAbsentLeafPtyVerdicts.set(ptyId, Date.now()) + const now = Date.now() + this.pruneExpiredLeafPtyVerdicts(now) + this.provenAbsentLeafPtyVerdicts.set(ptyId, now) return true } catch { // Why: a failed probe is unknown, and unknown never rejects a write. diff --git a/src/main/runtime/proven-absent-leaf-pty-verdicts.test.ts b/src/main/runtime/proven-absent-leaf-pty-verdicts.test.ts new file mode 100644 index 00000000000..eb6580c2e43 --- /dev/null +++ b/src/main/runtime/proven-absent-leaf-pty-verdicts.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { pruneExpiredProvenAbsentLeafPtyVerdicts } from './proven-absent-leaf-pty-verdicts' + +describe('pruneExpiredProvenAbsentLeafPtyVerdicts', () => { + it('removes only entries at or past the TTL without a re-probe', () => { + const map = new Map([ + ['live-dead', 1_000], + ['still-fresh', 1_400], + ['exact-expiry', 1_000] + ]) + pruneExpiredProvenAbsentLeafPtyVerdicts(map, 1_000 + 15_000, 15_000) + expect([...map.keys()]).toEqual(['still-fresh']) + }) + + it('leaves an empty map alone', () => { + const map = new Map() + pruneExpiredProvenAbsentLeafPtyVerdicts(map, Date.now(), 15_000) + expect(map.size).toBe(0) + }) + + it('clears everything when ttl is non-positive', () => { + const map = new Map([['a', 1]]) + pruneExpiredProvenAbsentLeafPtyVerdicts(map, 100, 0) + expect(map.size).toBe(0) + }) +}) diff --git a/src/main/runtime/proven-absent-leaf-pty-verdicts.ts b/src/main/runtime/proven-absent-leaf-pty-verdicts.ts new file mode 100644 index 00000000000..dea6d61ca3e --- /dev/null +++ b/src/main/runtime/proven-absent-leaf-pty-verdicts.ts @@ -0,0 +1,16 @@ +/** Drop cache entries whose TTL has elapsed without requiring a re-probe of that ptyId. */ +export function pruneExpiredProvenAbsentLeafPtyVerdicts( + verdicts: Map, + nowMs: number, + ttlMs: number +): void { + if (ttlMs <= 0) { + verdicts.clear() + return + } + for (const [ptyId, verdictAt] of verdicts) { + if (nowMs - verdictAt >= ttlMs) { + verdicts.delete(ptyId) + } + } +} diff --git a/src/main/runtime/push/desktop-push-service.test.ts b/src/main/runtime/push/desktop-push-service.test.ts index 1300b43c3fb..a9561be8487 100644 --- a/src/main/runtime/push/desktop-push-service.test.ts +++ b/src/main/runtime/push/desktop-push-service.test.ts @@ -313,3 +313,40 @@ it('renews a seven-day mobile lease only on explicit registration', async () => clock.mockRestore() } }) + +it('sends an explicit test only to the requesting registered phone and awaits gateway acceptance', async () => { + const { service, registry, deviceId, send } = createService() + await service.register({ + ...REGISTER_INPUT, + deviceId, + filter: { onlyWhenDesktopAway: true, sound: false } + }) + registry.addDevice('another phone', 'mobile') + send.mockResolvedValue({ ok: true, results: [{ registrationId: 'reg-1', status: 'queued' }] }) + await expect(service.test(deviceId)).resolves.toEqual({ accepted: true }) + expect(send).toHaveBeenCalledWith({ + registrationIds: ['reg-1'], + notification: expect.objectContaining({ + source: 'terminal-bell', + sound: false, + title: 'Test notification' + }) + }) +}) + +it('does not claim success for missing registrations or failed gateway sends', async () => { + const { service, deviceId, send } = createService() + await expect(service.test(deviceId)).resolves.toEqual({ + accepted: false, + reason: 'not_registered' + }) + expect(send).not.toHaveBeenCalled() + await service.register({ ...REGISTER_INPUT, deviceId }) + send.mockResolvedValue({ ok: false, reason: 'unreachable' }) + await expect(service.test(deviceId)).resolves.toEqual({ accepted: false, reason: 'unavailable' }) + send.mockResolvedValue({ + ok: true, + results: [{ registrationId: 'reg-1', status: 'rate_limited' }] + }) + await expect(service.test(deviceId)).resolves.toEqual({ accepted: false, reason: 'rate_limited' }) +}) diff --git a/src/main/runtime/push/desktop-push-service.ts b/src/main/runtime/push/desktop-push-service.ts index 69bf810d026..44e06dbcb2c 100644 --- a/src/main/runtime/push/desktop-push-service.ts +++ b/src/main/runtime/push/desktop-push-service.ts @@ -2,7 +2,9 @@ // registration each paired phone asked for, and the durable delete queue. Built // alongside DesktopRelayService but deliberately not gated on cloud sign-in: the // gateway authenticates with the host keypair, so accountless hosts push too. +import { randomUUID } from 'node:crypto' import type { + MobilePushTestResult, MobilePushRegisterInput, MobilePushRegisterResult } from '../../../shared/mobile-push-contract' @@ -105,6 +107,53 @@ export class DesktopPushService { this.runtime.setMobilePushRegistrar(null) } + async test(deviceId: string): Promise { + const device = this.registry.getDevice(deviceId) + const registration = device?.pushRegistration + if (device?.scope !== 'mobile' || !registration || registration.expiresAt <= Date.now()) { + return { accepted: false, reason: 'not_registered' } + } + if (this.stopped) { + return { accepted: false, reason: 'unavailable' } + } + // Explicit tests target only the caller and bypass automatic activity filters. + const result = await this.client.send({ + registrationIds: [registration.registrationId], + notification: { + source: 'terminal-bell', + agentState: null, + title: 'Test notification', + body: '', + notificationId: randomUUID(), + notificationEpoch: randomUUID(), + notificationSeq: 0, + expiresAt: Date.now() + 300_000, + sound: registration.filter.sound !== false + } + }) + if (!result.ok) { + return { + accepted: false, + reason: result.reason === 'unreachable' ? 'unavailable' : 'rejected' + } + } + const status = result.results.find( + (entry) => entry.registrationId === registration.registrationId + )?.status + if (status === 'queued') { + return { accepted: true } + } + return { + accepted: false, + reason: + status === 'rate_limited' + ? 'rate_limited' + : status === 'dead' + ? 'not_registered' + : 'rejected' + } + } + async register(input: MobilePushRegisterInput): Promise { if (this.registry.getDevice(input.deviceId)?.scope !== 'mobile') { return { registered: false, reason: 'not_mobile' } diff --git a/src/main/runtime/push/push-registration-rpc.test.ts b/src/main/runtime/push/push-registration-rpc.test.ts index 78a91a238fa..f19bfe71b4d 100644 --- a/src/main/runtime/push/push-registration-rpc.test.ts +++ b/src/main/runtime/push/push-registration-rpc.test.ts @@ -30,6 +30,7 @@ function contextFor(overrides: Partial): RpcContext { registered: true, registrationId: 'reg-1' })), + testMobilePushDevice: vi.fn(async () => ({ accepted: true })), unregisterMobilePushDevice: vi.fn(async () => ({ unregistered: true })) }, ...overrides @@ -154,3 +155,25 @@ describe('revokeMobileDevice', () => { expect(server.getPushUnregisterOutbox().pending()).toEqual([]) }) }) + +describe('notifications.testPush', () => { + it('targets the authenticated phone and returns the service result', async () => { + const ctx = contextFor({ clientKind: 'mobile', pairedDeviceId: 'device-1' }) + expect(await method('notifications.testPush').handler(null, ctx)).toEqual({ accepted: true }) + expect(ctx.runtime.testMobilePushDevice).toHaveBeenCalledWith('device-1') + }) + it('refuses callers without an authenticated mobile identity', async () => { + for (const overrides of [ + {}, + { clientKind: 'mobile' as const }, + { clientKind: 'runtime' as const, pairedDeviceId: 'device-1' } + ]) { + const ctx = contextFor(overrides) + expect(await method('notifications.testPush').handler(null, ctx)).toEqual({ + accepted: false, + reason: 'not_registered' + }) + expect(ctx.runtime.testMobilePushDevice).not.toHaveBeenCalled() + } + }) +}) diff --git a/src/main/runtime/relay/relay-control-client-options.ts b/src/main/runtime/relay/relay-control-client-options.ts index 5b98737d11a..93d1efc0fc6 100644 --- a/src/main/runtime/relay/relay-control-client-options.ts +++ b/src/main/runtime/relay/relay-control-client-options.ts @@ -1,9 +1,6 @@ import type WebSocket from 'ws' import type { E2EEKeypair } from '../e2ee-keypair' -import type { - RelayConnectionOpenMessage, - RelayDrainMessage, -} from './relay-control-protocol' +import type { RelayConnectionOpenMessage, RelayDrainMessage } from './relay-control-protocol' export type RelayControlClientOptions = { cellUrl: string diff --git a/src/main/runtime/relay/relay-control-protocol.ts b/src/main/runtime/relay/relay-control-protocol.ts index ba05d976ed7..b2cc7dd0232 100644 --- a/src/main/runtime/relay/relay-control-protocol.ts +++ b/src/main/runtime/relay/relay-control-protocol.ts @@ -76,7 +76,11 @@ export const RelayConnectionOpenMessageSchema = z export const RelayDrainMessageSchema = z .object({ type: z.literal('drain'), - graceMs: z.number().int().nonnegative().max(60 * 60 * 1000), + graceMs: z + .number() + .int() + .nonnegative() + .max(60 * 60 * 1000), recovery: z.literal('resolve-director') }) .strict() diff --git a/src/main/runtime/relay/relay-origin-pool.ts b/src/main/runtime/relay/relay-origin-pool.ts index 15abcc4127c..ec8bb2f539d 100644 --- a/src/main/runtime/relay/relay-origin-pool.ts +++ b/src/main/runtime/relay/relay-origin-pool.ts @@ -187,19 +187,14 @@ export class RelayOriginPool { } this.deferredAssignment = null if (assignment.cellUrl === origin.cellUrl) { - let rebound = false + let rebound = false try { await origin.rebind(this.relayJwt, assignment) rebound = true } catch { // Why: a restarted cell cannot know the prior process's resume secret; // after rebind fails, a fresh generation is the only recoverable path. - await this.activateTarget( - origin, - assignment, - this.relayJwt, - message.graceMs, - ) + await this.activateTarget(origin, assignment, this.relayJwt, message.graceMs) } if (rebound) { this.assertCurrent() @@ -208,12 +203,7 @@ export class RelayOriginPool { this.drainingOrigins.delete(origin) } } else { - await this.activateTarget( - origin, - assignment, - this.relayJwt, - message.graceMs, - ) + await this.activateTarget(origin, assignment, this.relayJwt, message.graceMs) } this.options.onStatus('registered') this.drainRetry.reset() diff --git a/src/main/runtime/rpc/methods/notifications.ts b/src/main/runtime/rpc/methods/notifications.ts index 8168da70cae..7df66b4acf4 100644 --- a/src/main/runtime/rpc/methods/notifications.ts +++ b/src/main/runtime/rpc/methods/notifications.ts @@ -87,6 +87,16 @@ export const NOTIFICATION_METHODS = [ return await runtime.registerMobilePushDevice({ ...params, deviceId: pairedDeviceId }) } }), + defineMethod({ + name: 'notifications.testPush', + params: null, + handler: async (_params, { runtime, clientKind, pairedDeviceId }) => { + if (clientKind !== 'mobile' || !pairedDeviceId) { + return { accepted: false, reason: 'not_registered' } + } + return await runtime.testMobilePushDevice(pairedDeviceId) + } + }), defineMethod({ name: 'notifications.unregisterPush', params: null, diff --git a/src/main/runtime/runtime-mobile-notification-controller.ts b/src/main/runtime/runtime-mobile-notification-controller.ts index 73578412618..174e897f4e1 100644 --- a/src/main/runtime/runtime-mobile-notification-controller.ts +++ b/src/main/runtime/runtime-mobile-notification-controller.ts @@ -1,6 +1,7 @@ import { reserveNotificationCooldown } from '../../shared/notification-burst-cooldown' import type { AgentStatusState } from '../../shared/agent-status-types' import type { + MobilePushTestResult, MobilePushRegisterInput, MobilePushRegisterResult } from '../../shared/mobile-push-contract' @@ -43,6 +44,7 @@ export type MobileNotificationEvent = /** The desktop push service, once it exists; absent on hosts that never started one. */ export type MobilePushRegistrar = { + test(deviceId: string): Promise register(input: MobilePushRegisterInput): Promise unregister(deviceId: string): Promise<{ unregistered: boolean }> } @@ -77,6 +79,10 @@ export class RuntimeMobileNotificationController { ) } + async testPushDevice(deviceId: string): Promise { + return (await this.pushRegistrar?.test(deviceId)) ?? { accepted: false, reason: 'unavailable' } + } + async unregisterPushDevice(deviceId: string): Promise<{ unregistered: boolean }> { return (await this.pushRegistrar?.unregister(deviceId)) ?? { unregistered: false } } diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts index ca8d326e7e7..85cf306a0e5 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts @@ -174,6 +174,7 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'notifications.getMissedSince', 'notifications.registerPush', 'notifications.subscribe', + 'notifications.testPush', 'notifications.unregisterPush', 'notifications.unsubscribe', 'pairing.getEndpoints', diff --git a/src/main/runtime/runtime-service-command-surface.ts b/src/main/runtime/runtime-service-command-surface.ts index b7811dbc073..4290fadecf1 100644 --- a/src/main/runtime/runtime-service-command-surface.ts +++ b/src/main/runtime/runtime-service-command-surface.ts @@ -33,6 +33,7 @@ export type RuntimeServiceCommandSurface = { dismissMobileNotification: RuntimeMobileNotificationController['dismiss'] dispatchPluginNotification: RuntimeMobileNotificationController['dispatchPlugin'] setMobilePushRegistrar: RuntimeMobileNotificationController['setPushRegistrar'] + testMobilePushDevice: RuntimeMobileNotificationController['testPushDevice'] registerMobilePushDevice: RuntimeMobileNotificationController['registerPushDevice'] unregisterMobilePushDevice: RuntimeMobileNotificationController['unregisterPushDevice'] setAccountServices: RuntimeAccountController['setServices'] @@ -118,6 +119,7 @@ export function installRuntimeServiceCommandSurface( dismissMobileNotification: notifications.dismiss.bind(notifications), dispatchPluginNotification: notifications.dispatchPlugin.bind(notifications), setMobilePushRegistrar: notifications.setPushRegistrar.bind(notifications), + testMobilePushDevice: notifications.testPushDevice.bind(notifications), registerMobilePushDevice: notifications.registerPushDevice.bind(notifications), unregisterMobilePushDevice: notifications.unregisterPushDevice.bind(notifications), setAccountServices: accounts.setServices.bind(accounts), diff --git a/src/main/runtime/structured-session-worktree-teardown.test.ts b/src/main/runtime/structured-session-worktree-teardown.test.ts index 84977aad4eb..bb022d4ba16 100644 --- a/src/main/runtime/structured-session-worktree-teardown.test.ts +++ b/src/main/runtime/structured-session-worktree-teardown.test.ts @@ -541,16 +541,20 @@ describe('worktree teardown and structured agent sessions', () => { records: [record('s1', WORKTREE), record('s2', WORKTREE)], closeGates: { s1: firstClose } }) - const error = await killAllProcessesForWorktree( - WORKTREE, - destructiveDeps({ timeoutMs: 5 }) - ).catch((thrown: Error) => thrown.message) - expect(error).toContain('could not confirm these closed: 2 agent sessions (claude)') - releaseFirstClose() - await new Promise((resolve) => { - setTimeout(resolve, 25) - }) - expect(host.closed).toEqual(['s1']) + vi.useFakeTimers() + try { + const outcome = killAllProcessesForWorktree( + WORKTREE, + destructiveDeps({ timeoutMs: 5 }) + ).catch((thrown: Error) => thrown.message) + await vi.advanceTimersByTimeAsync(5) + expect(await outcome).toContain('could not confirm these closed: 2 agent sessions (claude)') + releaseFirstClose() + await vi.advanceTimersByTimeAsync(0) + expect(host.closed).toEqual(['s1']) + } finally { + vi.useRealTimers() + } }) it('leaves the terminals already stopped when it refuses over a stuck session', async () => { diff --git a/src/main/wsl/wsl-guest-environment.test.ts b/src/main/wsl/wsl-guest-environment.test.ts index 0d14d9252b7..c249698e6f8 100644 --- a/src/main/wsl/wsl-guest-environment.test.ts +++ b/src/main/wsl/wsl-guest-environment.test.ts @@ -58,6 +58,33 @@ describe('probing', () => { await getWslGuestEnvironment('Debian') expect(runProcessMock).toHaveBeenCalledTimes(2) }) + + it('does not retain deadline timers after a concurrent probe settles', async () => { + vi.useFakeTimers() + try { + respondWithPayload(GOOD) + await Promise.all(Array.from({ length: 32 }, () => getWslGuestEnvironment('Ubuntu'))) + expect(vi.getTimerCount()).toBe(0) + expect(runProcessMock).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('reads a warm cache without allocating deadline timers', async () => { + respondWithPayload(GOOD) + const environment = await getWslGuestEnvironment('Ubuntu') + const timeout = vi.spyOn(globalThis, 'setTimeout') + try { + for (let index = 0; index < 100; index++) { + expect(await getWslGuestEnvironment('Ubuntu')).toBe(environment) + } + expect(timeout).not.toHaveBeenCalled() + expect(runProcessMock).toHaveBeenCalledTimes(1) + } finally { + timeout.mockRestore() + } + }) }) describe('bad answers are not cached as good ones', () => { diff --git a/src/main/wsl/wsl-guest-environment.ts b/src/main/wsl/wsl-guest-environment.ts index 7f154314afb..4edfb862236 100644 --- a/src/main/wsl/wsl-guest-environment.ts +++ b/src/main/wsl/wsl-guest-environment.ts @@ -125,6 +125,10 @@ export function getWslGuestEnvironment( budgetMs = PROBE_TIMEOUT_MS ): Promise { const key = cacheKey(distro) + const cached = resolved.get(key) + if (cached) { + return Promise.resolve(cached) + } const retry = retryAfter.get(key) if (retry !== undefined && Date.now() >= retry) { inFlight.delete(key) @@ -154,13 +158,14 @@ export function getWslGuestEnvironment( // Why race: joining an in-flight probe used to mean waiting out the // *starter's* budget, so a joiner could reach its own command with 1ms -- // the exact hazard the budget plumbing was added to remove. + let timer: ReturnType return Promise.race([ existing, new Promise((resolve) => { - const timer = setTimeout(() => resolve(null), budgetMs) + timer = setTimeout(() => resolve(null), budgetMs) timer.unref?.() }) - ]) + ]).finally(() => clearTimeout(timer)) } // Store before awaiting so a burst collapses into one probe. // Why catch: runProcess REJECTS when the child cannot be started (ENOENT on a diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 632e53005ae..75e849bf740 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1,4 +1,3 @@ -import type { ElectronAPI } from '@electron-toolkit/preload' import type { ClaudeAccountsApi, CodexAccountsApi, @@ -205,7 +204,6 @@ export type { declare global { // oxlint-disable-next-line typescript-eslint/consistent-type-definitions -- declaration merging requires interface interface Window { - electron: ElectronAPI api: PreloadApi } } diff --git a/src/preload/app-restart-checkpoint-routing.test.ts b/src/preload/app-restart-checkpoint-routing.test.ts index d794a86b9ab..b68f55989f5 100644 --- a/src/preload/app-restart-checkpoint-routing.test.ts +++ b/src/preload/app-restart-checkpoint-routing.test.ts @@ -26,8 +26,6 @@ vi.mock('electron', () => ({ webUtils: { getPathForFile: vi.fn(() => '') } })) -vi.mock('@electron-toolkit/preload', () => ({ electronAPI: {} })) - describe('native preload destructive app actions', () => { const originalContextIsolated = Object.getOwnPropertyDescriptor(process, 'contextIsolated') let eventTarget: EventTarget diff --git a/src/preload/index.ts b/src/preload/index.ts index 27d3ca8e062..f7bb5a30bcc 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,4 @@ import { contextBridge, ipcRenderer } from 'electron' -import { electronAPI } from '@electron-toolkit/preload' import type { PreloadApi } from './api-types' import { installBrowserFindListener, @@ -186,12 +185,10 @@ const api = { if (process.contextIsolated) { try { - contextBridge.exposeInMainWorld('electron', electronAPI) contextBridge.exposeInMainWorld('api', api) } catch (error) { console.error(error) } } else { - window.electron = electronAPI window.api = api } diff --git a/src/preload/pty-snapshot-capability-ipc.test.ts b/src/preload/pty-snapshot-capability-ipc.test.ts index 5c639edcfb7..f766b91a188 100644 --- a/src/preload/pty-snapshot-capability-ipc.test.ts +++ b/src/preload/pty-snapshot-capability-ipc.test.ts @@ -21,8 +21,6 @@ vi.mock('electron', () => ({ webUtils: { getPathForFile: vi.fn(() => '') } })) -vi.mock('@electron-toolkit/preload', () => ({ electronAPI: {} })) - describe('PTY snapshot capability preload IPC', () => { const originalContextIsolated = Object.getOwnPropertyDescriptor(process, 'contextIsolated') diff --git a/src/preload/ssh-authority-forwarding.test.ts b/src/preload/ssh-authority-forwarding.test.ts index 34179e1e7fa..05edf28e710 100644 --- a/src/preload/ssh-authority-forwarding.test.ts +++ b/src/preload/ssh-authority-forwarding.test.ts @@ -31,8 +31,6 @@ vi.mock('electron', () => ({ webUtils: { getPathForFile: vi.fn(() => '') } })) -vi.mock('@electron-toolkit/preload', () => ({ electronAPI: {} })) - describe('native preload SSH authority forwarding', () => { const originalContextIsolated = Object.getOwnPropertyDescriptor(process, 'contextIsolated') diff --git a/src/preload/updater-package-recovery.test.ts b/src/preload/updater-package-recovery.test.ts index b28d4632f60..7b9da47e76b 100644 --- a/src/preload/updater-package-recovery.test.ts +++ b/src/preload/updater-package-recovery.test.ts @@ -21,8 +21,6 @@ vi.mock('electron', () => ({ webUtils: { getPathForFile: vi.fn(() => '') } })) -vi.mock('@electron-toolkit/preload', () => ({ electronAPI: {} })) - describe('native preload linux package recovery methods', () => { const originalContextIsolated = Object.getOwnPropertyDescriptor(process, 'contextIsolated') diff --git a/src/relay/hermes-run-correlation.ts b/src/relay/hermes-run-correlation.ts index 75d3960a8e5..f98d71c2402 100644 --- a/src/relay/hermes-run-correlation.ts +++ b/src/relay/hermes-run-correlation.ts @@ -1,3 +1,4 @@ +import { HermesSessionRunIndex } from '../shared/hermes-session-run-index' const HERMES_RUN_KEY_PATTERN = /^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})$/ const MAX_SESSION_OUTPUT_GAP_MS = 24 * 60 * 60 * 1000 const FULL_SESSION_LOG_HEADING = '## Full session log' @@ -67,43 +68,6 @@ function sortableTimeFromRunKey(runKey: string | null): number { ) } -function findMatchingSessionRunIndex( - outputRun: unknown, - sessionRuns: unknown[], - usedSessionRunIndexes: Set -): number | null { - const outputRunKey = getRunKey(outputRun) - const exactMatchIndex = sessionRuns.findIndex( - (sessionRun, index) => - !usedSessionRunIndexes.has(index) && getRunKey(sessionRun) === outputRunKey - ) - if (exactMatchIndex !== -1) { - return exactMatchIndex - } - const outputTime = sortableTimeFromRunKey(outputRunKey) - if (!Number.isFinite(outputTime)) { - return null - } - let bestIndex: number | null = null - let bestGap = Number.POSITIVE_INFINITY - for (let index = 0; index < sessionRuns.length; index += 1) { - if (usedSessionRunIndexes.has(index)) { - continue - } - const sessionTime = sortableTimeFromRunKey(getRunKey(sessionRuns[index])) - if (!Number.isFinite(sessionTime)) { - continue - } - const gap = outputTime - sessionTime - if (gap < 0 || gap > MAX_SESSION_OUTPUT_GAP_MS || gap >= bestGap) { - continue - } - bestIndex = index - bestGap = gap - } - return bestIndex -} - function mergeOutputAndSessionContent( outputContent: string | null, sessionContent: string | null @@ -124,16 +88,17 @@ export function mergeHermesOutputAndSessionRuns( outputRuns: unknown[], sessionRuns: unknown[] ): unknown[] { - const usedSessionRunIndexes = new Set() + const sessionIndex = new HermesSessionRunIndex( + outputRuns.length > 0 ? sessionRuns.map(getRunKey) : [], + sortableTimeFromRunKey, + MAX_SESSION_OUTPUT_GAP_MS + ) + const usedSessionRunIndexes = sessionIndex.used const mergedOutputRuns = outputRuns.map((outputRun) => { if (!isRecord(outputRun)) { return outputRun } - const sessionRunIndex = findMatchingSessionRunIndex( - outputRun, - sessionRuns, - usedSessionRunIndexes - ) + const sessionRunIndex = sessionIndex.find(getRunKey(outputRun)) if (sessionRunIndex === null) { return outputRun } @@ -141,7 +106,7 @@ export function mergeHermesOutputAndSessionRuns( if (!isRecord(sessionRun)) { return outputRun } - usedSessionRunIndexes.add(sessionRunIndex) + sessionIndex.use(sessionRunIndex) return { ...outputRun, output_preview: getRunOutputPreview(outputRun) ?? getRunOutputPreview(sessionRun), @@ -161,16 +126,17 @@ export function mergeHermesOutputAndSessionRunRefs( outputRefs: HermesOutputRunRef[], sessionRefs: HermesSessionRunRef[] ): HermesMergedRunRef[] { - const usedSessionRunIndexes = new Set() + const sessionIndex = new HermesSessionRunIndex( + outputRefs.length > 0 ? sessionRefs.map(getRunKey) : [], + sortableTimeFromRunKey, + MAX_SESSION_OUTPUT_GAP_MS + ) + const usedSessionRunIndexes = sessionIndex.used const mergedOutputRefs = outputRefs.map((outputRef) => { - const sessionRunIndex = findMatchingSessionRunIndex( - outputRef, - sessionRefs, - usedSessionRunIndexes - ) + const sessionRunIndex = sessionIndex.find(getRunKey(outputRef)) const sessionRef = sessionRunIndex === null ? null : sessionRefs[sessionRunIndex] if (sessionRunIndex !== null) { - usedSessionRunIndexes.add(sessionRunIndex) + sessionIndex.use(sessionRunIndex) } return { id: outputRef.id, diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index a65dfe59e9a..8793b1f17ec 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1919,7 +1919,8 @@ html.native-shell .app-layout { overflow: visible; border-radius: var(--radius); opacity: 0.96; - filter: drop-shadow(0 10px 24px color-mix(in srgb, var(--foreground) 18%, transparent)); + background: var(--worktree-sidebar); + box-shadow: var(--shadow-floating); will-change: transform; } diff --git a/src/renderer/src/components/Landing.tsx b/src/renderer/src/components/Landing.tsx index 629836c58f6..eb309af405e 100644 --- a/src/renderer/src/components/Landing.tsx +++ b/src/renderer/src/components/Landing.tsx @@ -98,6 +98,12 @@ function GitHubStarButton({ 'cursor-pointer border-amber-500/50 bg-amber-400/10 text-amber-700 dark:border-amber-400/25 dark:bg-amber-400/[0.06] dark:text-amber-400/60' )} onClick={handleClick} + onContextMenu={(event) => { + if (state === 'starred') { + event.preventDefault() + setMenuOpen(true) + } + }} disabled={state === 'loading'} > {state === 'web-fallback' ? ( @@ -119,7 +125,7 @@ function GitHubStarButton({ : translate('auto.components.Landing.0d0ace8861', 'Star on GitHub')} {state === 'starred' && menuOpen && ( -
+
- - + ) } + +// Keep translation and element construction behind the dialog portal's mount boundary. +function CloseTerminalDialogBody({ + isAgent, + trimmedTabLabel, + checkboxId, + dontAskAgain, + setDontAskAgain, + onCancel, + onConfirm +}: { + isAgent: boolean + trimmedTabLabel: string | undefined + checkboxId: string + dontAskAgain: boolean + setDontAskAgain: (value: boolean) => void + onCancel: () => void + onConfirm: (dontAskAgain: boolean) => void +}): React.JSX.Element { + return ( + <> + + + {isAgent + ? translate( + 'auto.components.terminal.pane.CloseTerminalDialog.stop_agent_title', + 'Stop this agent?' + ) + : translate( + 'auto.components.terminal.pane.CloseTerminalDialog.stop_command_title', + 'Stop running command?' + )} + + + {isAgent + ? translate( + 'auto.components.terminal.pane.CloseTerminalDialog.stop_agent_description', + "Closing this terminal will stop the agent's current work." + ) + : translate( + 'auto.components.terminal.pane.CloseTerminalDialog.stop_command_description', + 'Closing this terminal will stop the command running inside it.' + )} + + + {trimmedTabLabel ? ( +

+ {trimmedTabLabel} +

+ ) : null} +
+ setDontAskAgain(checked === true)} + /> + +
+ + + + + + ) +} diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx index 58a39f28a50..bd25abcce74 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx @@ -2,6 +2,7 @@ import React from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import TerminalContextMenu from './TerminalContextMenu' +import { translate } from '@/i18n/i18n' import type { KeybindingOverrides } from '../../../../shared/keybindings' type ItemProps = { onSelect?: () => void; children?: React.ReactNode } @@ -13,9 +14,12 @@ vi.mock('@/components/ui/dropdown-menu', async () => { const React_ = await import('react') const passthrough = ({ children }: { children?: React.ReactNode }) => React_.createElement(React_.Fragment, null, children) + const OpenContext = React_.createContext(false) return { - DropdownMenu: passthrough, - DropdownMenuContent: passthrough, + DropdownMenu: ({ open, children }: { open: boolean; children?: React.ReactNode }) => + React_.createElement(OpenContext.Provider, { value: open }, children), + DropdownMenuContent: ({ children }: { children?: React.ReactNode }) => + React_.useContext(OpenContext) ? passthrough({ children }) : null, DropdownMenuLabel: passthrough, DropdownMenuSeparator: () => null, DropdownMenuShortcut: ({ children }: { children?: React.ReactNode }) => { @@ -36,7 +40,7 @@ vi.mock('@/components/ui/dropdown-menu', async () => { } } }) -vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/i18n/i18n', () => ({ translate: vi.fn((_key: string, fallback: string) => fallback) })) vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => null })) vi.mock('./terminal-context-menu-dismiss', () => ({ shouldIgnoreTerminalMenuPointerDownOutside: () => false @@ -104,6 +108,7 @@ function renderMenu(overrides: Record = {}): string { describe('TerminalContextMenu', () => { beforeEach(() => { + vi.mocked(translate).mockClear() items.list = [] shortcuts.list = [] vi.stubGlobal('navigator', { userAgent: 'Linux' }) @@ -113,6 +118,16 @@ describe('TerminalContextMenu', () => { vi.unstubAllGlobals() }) + it('does no menu-copy work while closed, then builds the opened menu', () => { + renderMenu({ open: false }) + expect(translate).not.toHaveBeenCalled() + expect(items.list).toHaveLength(0) + + renderMenu() + expect(translate).toHaveBeenCalled() + expect(items.list.length).toBeGreaterThan(0) + }) + it('renders a "Copy Context" item that triggers onCopyAgentSessionContext (issue #5020)', () => { const onCopyAgentSessionContext = vi.fn() const onForkAgentSession = vi.fn() @@ -167,6 +182,7 @@ describe('TerminalContextMenu', () => { item?.onSelect?.() expect(onCopyAgentSessionId).toHaveBeenCalledTimes(1) + vi.mocked(translate).mockClear() items.list = [] renderMenu({ canCopyAgentSessionId: false }) expect( diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx index 2cc76cd7164..5236d61d1a6 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx @@ -78,11 +78,59 @@ type TerminalContextMenuProps = { onCopyAgentSessionId: () => void } -export default function TerminalContextMenu({ - open, +export default function TerminalContextMenu(props: TerminalContextMenuProps): React.JSX.Element { + const { open, onOpenChange, menuPoint, menuOpenedAtRef } = props + return ( + { + if (!nextOpen && Date.now() - menuOpenedAtRef.current < 100) { + return + } + onOpenChange(nextOpen) + }} + modal={false} + > + +